diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8dc0ed..af8a4d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
+## [1.3.0][1.3.0] - 2025-01-13
+
+**This is a breaking change! banip is now installed via `uv`, and your
+existing data files need to move. Please see [README.md][banip] for
+more.**
+
+### Changed
+
+**breaking**: Migrate banip to an installable package ([#36][issue36]).
+
+### Fixed
+
+* Fix italics in markdown files ([#35][issue35])
+* Lint code and documentation.
+
+
+
## [1.2.0][1.2.0] - 2025-01-11
### Changed
@@ -137,3 +154,7 @@ _Initial Release._
[tomli]: https://pypi.org/project/tomli/
[1.2.0]: https://github.com/geozeke/banip/releases/tag/v1.2.0
[issue32]: https://github.com/geozeke/banip/issues/32
+[1.3.0]: https://github.com/geozeke/glinkfix/releases/tag/v1.3.0
+[issue35]: https://github.com/geozeke/banip/issues/35
+[banip]: https://github.com/geozeke/banip
+[issue36]: https://github.com/geozeke/banip/issues/36
diff --git a/README.md b/README.md
index afd79be..246e7ae 100644
--- a/README.md
+++ b/README.md
@@ -32,13 +32,14 @@ those whitelisted countries. This tool accomplishes that.
* [Running](#running)
* [Updating](#updating)
* [Plugins](#plugins)
+* [Upgrading](#upgrade)
* [Uninstalling](#uninstall)
## Requirements
### Operating System
-*banip* runs in Unix-like OSes. Either macOS, a Linux PC, Linux Virtual
+_banip_ runs in Unix-like OSes. Either macOS, a Linux PC, Linux Virtual
Machine, or [Windows Subsystem for Linux (WSL)][wsl] is required.
### MaxMind Database
@@ -62,7 +63,7 @@ GeoLite2 Country: CSV format
### uv
-*banip* requires [uv][astral] for dependency management. It is well
+_banip_ requires [uv][astral] for dependency management. It is well
behaved and extremely fast, and if you're a Python developer, you should
check it out. Visit the [uv site][astral] and install it using the
instructions for your operating system.
@@ -77,32 +78,17 @@ projects. The `global-gitignore.txt` file reflects my development setup
cherry-pick any necessary elements from `global-gitignore.txt` for your
own use.
-*Details on gitignore files are available on [GitHub][git-ignore].*
+_Details on gitignore files are available on [GitHub][git-ignore]._
### Global List of Blacklisted IPs
-*banip* uses the [ipsum][ipsum] threat intelligence blacklist. You can
+_banip_ uses the [ipsum][ipsum] threat intelligence blacklist. You can
direct download it using:
-```shell
+```text
curl -sL https://raw.githubusercontent.com/stamparm/ipsum/master/ipsum.txt > ipsum.txt
```
-### make
-
-You'll need the [make][make] utility installed (it probably already is).
-If not, install it with:
-
-```shell
-sudo apt install make
-```
-
-On macOS:
-
-```shell
-brew install make
-```
-
[top](#top)
## Setup
@@ -112,67 +98,66 @@ brew install make
Unpack the GeoLite2-Country zip archive and save the files to a location
you can easily get to.
-*Note: if you're looking for a quick way to download the MaxMind data
-using `curl` and a direct download permalink, [SEE HERE][mmd].*
-
-### Clone the Repository
+_Note: if you're looking for a quick way to download the MaxMind data
+using `curl` and a direct download permalink, [SEE HERE][mmd]._
-Clone this repository. We'll assume you clone it to your home directory
-(`~`):
+### Installing banip
-```shell
-git clone https://github.com/geozeke/banip.git
+```text
+uv tool install --from git+http://github.com/geozeke/banip.git banip
```
-Change to `~/banip` and run this command:
+### Create Required Directories
+
+Copy and paste the code below into a shell:
-```shell
-make setup
+```text
+mkdir -p ~/.banip/geolite ~/.banip/plugins/code ~/.banip/plugins/parsers
```
### Copy Files
#### GeoLite2 Files
-```shell
-cp /* ./data/geolite/
+```text
+cp /* ~/.banip/geolite/
```
#### ipsum Data
-```shell
-cp /ipsum.txt ./data/ipsum.txt
+```text
+cp /ipsum.txt ~/.banip/ipsum.txt
```
#### Targets
The global list of blacklisted IPs is massive. When you build a custom
-blacklist with *banip*, it's carefully tailored to just the countries
+blacklist with _banip_, it's carefully tailored to just the countries
you specify using a list of targets.
-```shell
-cp ./samples/targets.txt ./data/targets.txt
+```text
+cp ./samples/targets.txt ~/.banip/targets.txt
```
-Modify `./data/targets.txt` to select your desired target countries. The
-comments in the file will guide you.
+Modify `~/.banip/targets.txt` to select your desired target countries.
+The comments in the file will guide you.
#### Custom Whitelist (Optional)
-```shell
-cp ./samples/custom_whitelist.txt ./data/custom_whitelist.txt
+```text
+cp ./samples/custom_whitelist.txt ~/.banip/custom_whitelist.txt
```
-There may be IP addresses that *banip* will flag as malicious, but you
+There may be IP addresses that _banip_ will flag as malicious, but you
still want to whitelist them (for example, to use for testing). This
file should contain specific IP addresses, one per line, that you want
to allow. This file is optional, and if you choose not to use it,
-*banip* will create a blank one for you.
+_banip_ will create a blank one for you.
#### Custom Blacklist (Optional)
-```shell
-cp ./samples/custom_blacklist.txt ./data/custom_blacklist.txt
+```text
+cp ./samples/custom_blacklist.txt ~/.banip/custom_blacklist.txt
```
The ipsum database isn't perfect. You may determine that there's an IP
@@ -181,22 +166,22 @@ address you want to ban that is not found in `ipsum.txt`. Also, the
entire subnet. The custom blacklist allows you to capture specific IP
addresses or subnets (in [CIDR][cidr] format), one per line, that you
want to block. Some of your custom blacklist IPs may be found when you
-run the *banip*, so this file (`custom_blacklist.txt`) will be
+run the _banip_, so this file (`custom_blacklist.txt`) will be
overwritten to remove the duplicates. The contents of the de-duplicated
file will then be appended to the list generated when you run the
program. Like the whitelist, this file is optional. If you choose not to
-use it, *banip* will create a blank one when you run it.
+use it, _banip_ will create a blank one when you run it.
-*Note: If you're concerned about keeping your original list of custom
-blacklisted IPs, save a copy of it somewhere outside the repository.*
+_Note: If you're concerned about keeping your original list of custom
+blacklisted IPs, save a copy of it somewhere outside of `~/.banip`._
-When you're done, the `~/banip/data` directory should look like this:
+When you're done, the `~/.banip` directory should look like this:
```text
-data
+.banip
├── custom_blacklist.txt (optional)
├── custom_whitelist.txt (optional)
-├── geolite (required)
+├── geolite
│ ├── COPYRIGHT.txt
│ ├── GeoLite2-Country-Blocks-IPv4.csv
│ ├── GeoLite2-Country-Blocks-IPv6.csv
@@ -210,6 +195,9 @@ data
│ ├── GeoLite2-Country-Locations-zh-CN.csv
│ └── LICENSE.txt
├── ipsum.txt (required)
+├── plugins (required)
+│ ├── code (required)
+│ └── parsers (required)
└── targets.txt (required)
```
@@ -220,13 +208,9 @@ data
After copying/tweaking all the required files, start by activating the
Python virtual environment:
-```shell
-source .venv/bin/activate
-```
+Run this command to learn how to build your custom blacklist:
-Now run this command to learn how to build your custom blacklist:
-
-```shell
+```text
banip -h
```
@@ -237,39 +221,38 @@ banip -h
MaxMind updates the GeoLite2 Country database on Tuesdays and Fridays,
and the list of blacklisted IPs (`ipsum.txt`) is updated daily. Pull
updated copies of both and put them in `banip/data/geolite` (for the
-GeoLite2 data) and `banip/data` (for the `ipsum.txt` file). Run *banip*
+GeoLite2 data) and `banip/data` (for the `ipsum.txt` file). Run _banip_
again to generate an updated blacklist.
-*I recommend you automate all this using cron to keep your lists fresh.*
+_I recommend you automate all this using cron to keep your lists fresh._
[top](#top)
## Plugins
-*banip* generates some useful build products that you may want to use
+_banip_ generates some useful build products that you may want to use
for other purposes. For example, every time you build a new blacklist,
-*banip* also creates and saves a text file of all worldwide subnets,
+_banip_ also creates and saves a text file of all worldwide subnets,
each tagged with a two-letter country code. The file is saved in:
```'text
-./banip/data/haproxy_geo_ip.txt
+~/.banip/haproxy_geo_ip.txt
```
-Next time you run *banip*, open that file and take a look at it. Since
+Next time you run _banip_, open that file and take a look at it. Since
you may have a very specific use case for that data, you can write a
-plugin for *banip* which will make use of the build products for your
+plugin for _banip_ which will make use of the build products for your
purposes.
-A *banip* plugin consists of two required files:
+A _banip_ plugin consists of two required files:
1. Code that generates an argument parser for your new command.
2. Code that implements the functionality of your new command.
-All your plugins go into the `./src/plugins` directory in the
-appropriate subdirectory (either `parsers` or `code`). Your plugins are
-not under version control, so they will only reside on your machine.
-Look at the comments in these two files for instructions on how to
-create your own plugins:
+All your plugins go into the `~/.banip/plugins` directory in the
+appropriate subdirectory (either `parsers` or `code`). Look at the
+comments in these two files for instructions on how to create your own
+plugins:
```text
./samples/plugins/foo.py
@@ -278,12 +261,23 @@ create your own plugins:
[top](#top)
+## Upgrading banip
+
+To upgrade the code for _banip_, run:
+
+```text
+uv tool upgrade banip
+```
+
+[top](#top)
+
## Uninstalling banip
If you want out, just do this:
-```shell
-rm -rf ~/banip
+```text
+uv tool uninstall banip
+rm -rf ~/.banip
```
[top](#top)
@@ -293,7 +287,6 @@ rm -rf ~/banip
[git-ignore]: https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
[mmd]: https://dev.maxmind.com/geoip/updating-databases#directly-downloading-databases
[mmgeo]: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
-[make]: https://man7.org/linux/man-pages/man1/make.1p.html
[wsl]: https://docs.microsoft.com/en-us/windows/wsl/install
[mmh]: https://www.maxmind.com/en/home
[ipsum]: https://github.com/stamparm/ipsum
diff --git a/pyproject.toml b/pyproject.toml
index ec88273..55508b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,14 +1,13 @@
[project]
name = "banip"
-version = "1.2.0"
+version = "1.3.0"
description = "Create a list of banned IPs for specific countries"
license = {file = "LICENSE"}
readme = {file = "README.md", content-type = "text/markdown"}
-requires-python = ">=3.12,<3.13"
+requires-python = ">=3.12"
dependencies = [
- "banip",
"rich>=13.9.4",
- "tomli>=1.1.0 ; python_full_version < '3.11'",
+ "tomli>=2.2.1",
]
authors = [
{name = "Peter Nardi", email = "geozeke@gmail.com"},
diff --git a/src/banip/app.py b/src/banip/app.py
index 8d9a8aa..b7ea88c 100755
--- a/src/banip/app.py
+++ b/src/banip/app.py
@@ -4,19 +4,79 @@
import argparse
import importlib
+import importlib.util
import sys
from pathlib import Path
from types import ModuleType
from banip.constants import APP_NAME
from banip.constants import ARG_PARSERS_BASE
-from banip.constants import ARG_PARSERS_CUSTOM
from banip.constants import CUSTOM_CODE
+from banip.constants import CUSTOM_PARSERS
+from banip.constants import DATA
+from banip.utilities import print_docstring
from banip.version import get_version
# ======================================================================
+def check_setup() -> bool:
+ """Make sure the environment was propert set up.
+
+ Returns
+ -------
+ bool:
+ True if everything is in place; False otherwise.
+ """
+ proper_setup = (
+ CUSTOM_PARSERS.exists()
+ and CUSTOM_CODE.exists()
+ and (DATA / "geolite").exists()
+ ) # fmt: off
+ if not proper_setup:
+ msg = """
+ It looks like the environment was not set properly. Please
+ ensure the following structure exists in your home directory:
+
+ .banip
+ ├── geolite
+ └── plugins
+ ├── code
+ └── parsers
+ """
+ print_docstring(msg=msg)
+ return False
+ return True
+
+
+# ======================================================================
+
+
+def load_custom_module(mod_name: str, location: Path) -> ModuleType:
+ """Load a custom module.
+
+ Parameters
+ ----------
+ mod_name : str
+ The name of the module to load.
+ location : Path
+ The absolute path of the python code for the module.
+
+ Returns
+ -------
+ ModuleType
+ A pointer to module that gets loaded.
+ """
+ mod_path = f"{location}/{mod_name}.py"
+ if spec := importlib.util.spec_from_file_location(mod_name, mod_path):
+ if (module := importlib.util.module_from_spec(spec)) and spec.loader:
+ spec.loader.exec_module(module)
+ return module
+
+
+# ======================================================================
+
+
def collect_parsers(start: Path) -> list[str]:
"""Collect the module names of all argument parsers to import.
@@ -32,7 +92,7 @@ def collect_parsers(start: Path) -> list[str]:
"""
parser_names: list[str] = []
for p in start.iterdir():
- if p.is_file() and p.name != "__init__.py":
+ if p.is_file() and p.name.endswith(".py") and p.name != "__init__.py":
if "plugins" in str(p):
prefix = "plugins.parsers"
else:
@@ -46,6 +106,10 @@ def collect_parsers(start: Path) -> list[str]:
def main() -> None:
"""Get user input and build the list of banned IP addresses."""
+ # Make sure setup was properly completed.
+ if not check_setup():
+ return
+
msg = """
Generate and query IP blacklists for use with proxy servers (like
HAProxy). Please review the README file at
@@ -53,22 +117,12 @@ def main() -> None:
setting up banip.
"""
epi = f"Version: {get_version()}"
- parser = argparse.ArgumentParser(
- description=msg,
- epilog=epi,
- )
+ parser = argparse.ArgumentParser(description=msg, epilog=epi)
parser.add_argument(
- "-v",
- "--version",
- action="version",
- version=f"{APP_NAME} {get_version()}",
+ "-v", "--version", action="version", version=f"{APP_NAME} {get_version()}"
)
msg = "For help on any command below, run: banip {command} -h."
- subparsers = parser.add_subparsers(
- title="commands",
- dest="cmd",
- description=msg,
- )
+ subparsers = parser.add_subparsers(title="commands", dest="cmd", description=msg)
# Dynamically load argument subparsers and process command line
# arguments.
@@ -76,10 +130,15 @@ def main() -> None:
parser_names: list[str] = []
mod: ModuleType | None = None
parser_names = collect_parsers(ARG_PARSERS_BASE)
- parser_names += collect_parsers(ARG_PARSERS_CUSTOM)
- parser_names = sorted(parser_names, key=lambda x: x.split(".")[1])
+ parser_names += collect_parsers(CUSTOM_PARSERS)
+ parser_names = sorted(parser_names, key=lambda x: x.split(".")[-1])
for p_name in parser_names:
- parser_code = importlib.import_module(p_name)
+ if "plugins" not in p_name:
+ parser_code = importlib.import_module(f"banip.{p_name}")
+ else:
+ parser_code = load_custom_module(
+ p_name.split(".")[-1], location=CUSTOM_PARSERS
+ )
parser_code.load_command_args(subparsers)
args = parser.parse_args()
@@ -91,23 +150,23 @@ def main() -> None:
# prefix based on that.
if args.cmd:
- if f"parsers.{args.cmd}_args" in parser_names:
- prefix = f"{APP_NAME}"
- else:
- prefix = "plugins.code"
try:
- mod = importlib.import_module(f"{prefix}.{args.cmd}")
- except ModuleNotFoundError:
+ if f"parsers.{args.cmd}_args" in parser_names:
+ mod_name = f"{APP_NAME}.{args.cmd}"
+ mod = importlib.import_module(mod_name)
+ else:
+ mod = load_custom_module(args.cmd, location=CUSTOM_CODE)
+ except (ModuleNotFoundError, FileNotFoundError):
msg = f"""
Code for a custom command must have the same filename as the
- command itself. Make sure you have a program file called
- \"{args.cmd}.py\" in:
- {CUSTOM_CODE.parent}/
+ command itself. Make sure you have a python file called
+ \"{args.cmd}.py\" in: {CUSTOM_CODE}
"""
print("\n".join([line.strip() for line in msg.split("\n")]))
sys.exit(1)
else:
mod = importlib.import_module(f"{APP_NAME}.null")
+
mod.task_runner(args)
return
diff --git a/src/banip/constants.py b/src/banip/constants.py
index 7e7f50e..3ba6664 100644
--- a/src/banip/constants.py
+++ b/src/banip/constants.py
@@ -7,12 +7,14 @@
from pathlib import Path
from typing import TypeAlias
-HOME = Path(__file__).parents[2]
-DATA = HOME / "data"
+HOME = Path.home()
+BASE = Path(__file__).parents[0]
+DATA = HOME / ".banip"
APP_NAME = "banip"
-ARG_PARSERS_BASE = HOME / "src" / "parsers"
-ARG_PARSERS_CUSTOM = HOME / "src" / "plugins" / "parsers"
+ARG_PARSERS_BASE = BASE / "parsers"
+CUSTOM_CODE = DATA / "plugins" / "code"
+CUSTOM_PARSERS = DATA / "plugins" / "parsers"
COUNTRY_NETS_TXT = DATA / "haproxy_geo_ip.txt"
COUNTRY_NETS_DICT = DATA / "haproxy_geo_ip_dict.bin"
COUNTRY_WHITELIST = DATA / "country_whitelist.txt"
@@ -20,7 +22,6 @@
CUSTOM_WHITELIST = DATA / "custom_whitelist.txt"
RENDERED_BLACKLIST = DATA / "ip_blacklist.txt"
RENDERED_WHITELIST = DATA / "ip_whitelist.txt"
-CUSTOM_CODE = HOME / "src" / "plugins" / "code"
GEOLITE_4 = DATA / "geolite" / "GeoLite2-Country-Blocks-IPv4.csv"
GEOLITE_6 = DATA / "geolite" / "GeoLite2-Country-Blocks-IPv6.csv"
GEOLITE_LOC = DATA / "geolite" / "GeoLite2-Country-Locations-en.csv"
diff --git a/src/parsers/__init__.py b/src/banip/parsers/__init__.py
similarity index 100%
rename from src/parsers/__init__.py
rename to src/banip/parsers/__init__.py
diff --git a/src/parsers/build_args.py b/src/banip/parsers/build_args.py
similarity index 100%
rename from src/parsers/build_args.py
rename to src/banip/parsers/build_args.py
diff --git a/src/parsers/check_args.py b/src/banip/parsers/check_args.py
similarity index 100%
rename from src/parsers/check_args.py
rename to src/banip/parsers/check_args.py
diff --git a/src/parsers/stats_args.py b/src/banip/parsers/stats_args.py
similarity index 100%
rename from src/parsers/stats_args.py
rename to src/banip/parsers/stats_args.py
diff --git a/src/banip/utilities.py b/src/banip/utilities.py
index bf9e7bd..bf7aa3e 100644
--- a/src/banip/utilities.py
+++ b/src/banip/utilities.py
@@ -20,6 +20,43 @@
# ======================================================================
+def print_docstring(msg: str) -> None:
+ """Print a formatted docstring.
+
+ This function assumes the docstring is in a very specific format:
+
+ >>> msg =
+ >>> First line (non-blank)
+ >>>
+ >>> Subsequent lines
+ >>> Subsequent lines
+ >>> Subsequent lines
+ >>> ...
+ >>> Can include empty lines after the first.
+ >>>
+
+ Parameters
+ ----------
+ msg : str
+ The docstring to be printed.
+ """
+ # Delete the first line ('\n' by itself), remove any leading padding
+ # from the string, then print.
+ lines = msg.split("\n")[1:]
+ spaces = 0
+ for c in lines[0]:
+ if c in ["\n", " "]:
+ spaces += 1
+ else:
+ break
+ formatted_docstring = "\n".join([line[spaces:] for line in lines])
+ print(formatted_docstring)
+ return
+
+
+# ======================================================================
+
+
def split_hybrid(
hybrid_list: list[AddressType | NetworkType],
) -> tuple[list[AddressType], list[NetworkType]]:
diff --git a/src/banip/version.py b/src/banip/version.py
index e796cf8..e8f9076 100644
--- a/src/banip/version.py
+++ b/src/banip/version.py
@@ -1,11 +1,6 @@
-import sys
+import tomllib
from pathlib import Path
-if sys.version_info >= (3, 11):
- import tomllib
-else:
- import tomli as tomllib
-
def get_version() -> str:
"""Return the version number of the project.
diff --git a/uv.lock b/uv.lock
index 86bc0ab..7a01cbd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,12 +1,13 @@
version = 1
-requires-python = "==3.12.*"
+requires-python = ">=3.12"
[[package]]
name = "banip"
-version = "1.1.4"
+version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
+ { name = "tomli" },
]
[package.dev-dependencies]
@@ -17,9 +18,8 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "banip", editable = "." },
{ name = "rich", specifier = ">=13.9.4" },
- { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" },
+ { name = "tomli", specifier = ">=2.2.1" },
]
[package.metadata.requires-dev]
@@ -65,6 +65,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341 },
{ url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991 },
{ url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016 },
+ { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097 },
+ { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728 },
+ { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965 },
+ { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 },
+ { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 },
+ { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 },
{ url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905 },
]
@@ -124,6 +130,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708 },
]
+[[package]]
+name = "tomli"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 },
+ { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 },
+ { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 },
+ { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 },
+ { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 },
+ { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 },
+ { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 },
+ { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 },
+ { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
+ { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
+ { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 },
+ { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 },
+ { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 },
+ { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 },
+ { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 },
+ { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 },
+ { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 },
+ { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
+ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
+ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
+]
+
[[package]]
name = "typing-extensions"
version = "4.12.2"