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"