diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c302e7c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install uv + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install the project + run: uv sync --locked --all-extras --dev + + - name: Build project + run: uv build + + - name: Run tests + run: uv run pytest + + - name: Minimize uv cache + run: uv cache prune --ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b879547..e72a557 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,13 +9,15 @@ on: jobs: release: name: Release - uses: ctfpilot/ci/.github/workflows/release.yml@v1.3.0 + uses: ctfpilot/ci/.github/workflows/release.yml@v1.4.0 permissions: contents: write packages: write id-token: write secrets: RELEASE_GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN }} + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} with: repository: ctfpilot/challenge-toolkit + plugins: "semantic-release-pypi" ENVIRONMENT: Release diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..d8381ff --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,51 @@ +{ + "branches": [ + "main", + { + "name": "develop", + "prerelease": "rc" + } + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits" + } + ], + [ + "@semantic-release/exec", + { + "prepareCmd": "echo ${nextRelease.version} > version.txt", + "publishCmd": "echo 'Published version ${nextRelease.version}'" + } + ], + [ + "semantic-release-pypi", + { + "pypiPublish": false + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "pyproject.toml" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "successComment": false + } + ] + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 9a7b52c..c07f62f 100644 --- a/README.md +++ b/README.md @@ -16,30 +16,28 @@ The Challenge Toolkit streamlines the entire CTF challenge lifecycle, from boots ## How to run -> [!NOTE] -> We are currently working on making it easier to use the tool. +The project uses standard Python packaging, therefore it can be installed by virtually any Python package manager. -The current tool is only provided as the raw python files. -Therefore, in order to run the tool, first clone this repository: +> [!IMPORTANT] +> We recommend always locking the version you install. +> This can be done by adding `@` at the end of the install command, such as `@v1.2.0` to pin the install to `v1.2.0`. -```sh -git clone https://github.com/ctfpilot/challenge-toolkit ``` - -In order to install required dependencies, run: - -```sh -pip install -r challenge-toolkit/src/requirements.txt +uv tool install git+https://github.com/ctfpilot/challenge-toolkit +# or using pipx +pipx install git+https://github.com/ctfpilot/challenge-toolkit +# or using pip (be sure to use a virtual environment) +pip install git+https://github.com/ctfpilot/challenge-toolkit ``` > [!IMPORTANT] > The tool assumes, that the current working directory is the root of a challenge repository. > Read more about the expected structure of a challenge repository in the **[Challenge repository structure documentation](#challenge-repository-structure)** section. -You can then run the tool using python: +You can then run the tool from the command line: ```sh -python challenge-toolkit/src/ctf.py [arguments] [options] +challenge-toolkit [arguments] [options] ``` > [!IMPORTANT] @@ -47,11 +45,7 @@ python challenge-toolkit/src/ctf.py [arguments] [options] In order to use `create`, `template`, and `page` you need to copy the deployment templates into the `template/` directory of your challenge repository (In accordance with the **[Template structure](#template-structure)** section). -This can be done by running: - -```sh -cp -r challenge-toolkit/template/ . -``` +Example templates can be found in the [`template` directory](./template) ### Environment Variables @@ -65,32 +59,12 @@ The toolkit supports the following optional environment variables: Currently, the following dependencies are required: -- Python 3.8 or higher +- Python 3.10 or higher - `pyyaml` Python package - `python-slugify` Python package - Docker (for building challenge images with the `pipeline` command) -`pyyaml` and `python-slugify` are defined in the `requirements.txt` file. - -### Including the tool in your project as a git submodule - -One way to include it into your own project is to add it as a git submodule: - -```sh -git submodule add https://github.com/ctfpilot/challenge-toolkit -``` - -To then clone your own project with the submodule included, run: - -```sh -git clone --recurse-submodules -``` - -Or if you already have cloned your repository, run: - -```sh -git submodule update --init --recursive -``` +Python dependencies are listed in the `pyproject.toml` file and are automatically installed when installing the package via pip or similar tools. ### Typical usage @@ -156,7 +130,7 @@ DEFAULT = { The toolkit provides several commands to manage CTF challenges throughout their lifecycle. All commands follow the format: ```sh -python challenge-toolkit/src/ctf.py [arguments] [options] +challenge-toolkit [arguments] [options] ``` ### Command Overview @@ -180,7 +154,7 @@ Bootstrap a new challenge with the proper directory structure and template files > The new challenge will then be located in `challenges///`. ```sh -python challenge-toolkit/src/ctf.py create [options] +challenge-toolkit create [options] ``` **Options:** @@ -208,10 +182,10 @@ python challenge-toolkit/src/ctf.py create [options] ```sh # Interactive mode (recommended for first-time users) -python challenge-toolkit/src/ctf.py create +challenge-toolkit create # Non-interactive mode with all parameters -python challenge-toolkit/src/ctf.py create \ +challenge-toolkit create \ --no-prompts \ --name "SQL Injection 101" \ --slug "sql-injection-101" \ @@ -235,7 +209,7 @@ Generate Kubernetes deployment files, ConfigMaps, or handout archives for challe > The command should be run from the root of a challenge repository, as it relies on the challenge directory structure defined in the [Challenge repository structure](#challenge-repository-structure) section. ```sh -python challenge-toolkit/src/ctf.py template [options] +challenge-toolkit template [options] ``` **Arguments:** @@ -298,18 +272,18 @@ python challenge-toolkit/src/ctf.py template [options] ```sh # Generate Kubernetes deployment files -python challenge-toolkit/src/ctf.py template k8s web/sql-injection-101 +challenge-toolkit template k8s web/sql-injection-101 # Generate ConfigMap with custom expiry time (2 hours) and repo -python challenge-toolkit/src/ctf.py template configmap web/sql-injection-101 \ +challenge-toolkit template configmap web/sql-injection-101 \ --expires 7200 \ --repo ctfpilot/ctf-challenges # Create handout archive -python challenge-toolkit/src/ctf.py template handout web/sql-injection-101 +challenge-toolkit template handout web/sql-injection-101 # Clean generated files -python challenge-toolkit/src/ctf.py template clean web/sql-injection-101 +challenge-toolkit template clean web/sql-injection-101 ``` ### `pipeline` - Build and tag Docker images @@ -322,7 +296,7 @@ Build Docker images for challenges and tag them appropriately for container regi > The command should be run from the root of a challenge repository, as it relies on the challenge directory structure defined in the [Challenge repository structure](#challenge-repository-structure) section. ```sh -python challenge-toolkit/src/ctf.py pipeline [options] +challenge-toolkit pipeline [options] ``` **Arguments:** @@ -350,13 +324,13 @@ python challenge-toolkit/src/ctf.py pipeline The command should be run from the root of a challenge repository, as it relies on the challenge directory structure defined in the [Challenge repository structure](#challenge-repository-structure) section. ```sh -python challenge-toolkit/src/ctf.py page [options] +challenge-toolkit page [options] ``` **Arguments:** @@ -398,10 +372,10 @@ python challenge-toolkit/src/ctf.py page [options] ```sh # Render a custom page -python challenge-toolkit/src/ctf.py page rules --repo ctfpilot/ctf-challenges +challenge-toolkit page rules --repo ctfpilot/ctf-challenges # Render about page -python challenge-toolkit/src/ctf.py page about +challenge-toolkit page about ``` ### `slugify` - Convert strings to URL-safe slugs @@ -411,7 +385,7 @@ Utility command to convert challenge names into URL-safe slugs following the too **Usage:** ```sh -python challenge-toolkit/src/ctf.py slugify +challenge-toolkit slugify ``` **Arguments:** @@ -424,11 +398,11 @@ python challenge-toolkit/src/ctf.py slugify ```sh # Convert challenge name to slug -python challenge-toolkit/src/ctf.py slugify "SQL Injection 101" +challenge-toolkit slugify "SQL Injection 101" # Output: sql-injection-101 # Convert with special characters -python challenge-toolkit/src/ctf.py slugify "Web: XSS & CSRF" +challenge-toolkit slugify "Web: XSS & CSRF" # Output: web-xss-csrf ``` @@ -462,7 +436,6 @@ The structure is as follows: ├── pages/ │ └── page-1/ ├── template/ -├── challenge-toolkit/ └── ``` @@ -620,6 +593,16 @@ We welcome contributions of all kinds, from **code** and **documentation** to ** Please check the [Contribution Guidelines (`CONTRIBUTING.md`)](/CONTRIBUTING.md) for detailed guidelines on how to contribute. +### Running tests + +To run the test suite, ensure you have all development dependencies installed. You can then execute the tests using `pytest`: + +```sh +uv run pytest +``` + +### Contributor License Agreement (CLA) + To maintain the ability to distribute contributions across all our licensing models, **all code contributions require signing a Contributor License Agreement (CLA)**. You can review **[the CLA here](https://github.com/ctfpilot/cla)**. CLA signing happens automatically when you create your first pull request. To administrate the CLA signing process, we are using **[CLA assistant lite](https://github.com/marketplace/actions/cla-assistant-lite)**. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7f2ef19 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "challenge-toolkit" +version = "1.2.0rc1" +description = "CTF Pilot's Challenge Toolkit" +readme = "README.md" +requires-python = ">=3.10" +license = "EUPL-1.2" +license-files = ["LICENSE"] +authors = [ + { name = "Mikkel Albrechtsen", email = "developer@themikkel.dk" }, +] +maintainers = [ + { name = "Mikkel Albrechtsen", email = "developer@themikkel.dk" }, +] +keywords = [ + "ctf", + "ctf-pilot", + "security", + "tools", +] +dependencies = [ + "python-slugify==8.0.4", + "pyyaml==6.0.2", +] + +[project.urls] +Changelog = "https://github.com/ctfpilot/challenge-toolkit/releases/" +Homepage = "https://github.com/ctfpilot/challenge-toolkit/" +Issues = "https://github.com/ctfpilot/challenge-toolkit/issues/" +Repository = "https://github.com/ctfpilot/challenge-toolkit.git" + +[project.scripts] +challenge-toolkit = "challenge_toolkit.cli:main" + +[build-system] +requires = ["hatchling >= 1.26"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/pytest.toml b/pytest.toml new file mode 100644 index 0000000..1df6ba1 --- /dev/null +++ b/pytest.toml @@ -0,0 +1,4 @@ +[pytest] +# Suggested by pytest +# https://docs.pytest.org/en/stable/explanation/goodpractices.html#choosing-an-import-mode +addopts = ["--import-mode=importlib"] diff --git a/src/challenge_toolkit/__init__.py b/src/challenge_toolkit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ctf.py b/src/challenge_toolkit/cli.py similarity index 83% rename from src/ctf.py rename to src/challenge_toolkit/cli.py index 28e2fcb..74a145c 100644 --- a/src/ctf.py +++ b/src/challenge_toolkit/cli.py @@ -2,16 +2,16 @@ import argparse -from commands.challenge_creator import ChallengeCreator -from commands.template_renderer import TemplateRenderer -from commands.page import PageCommand -from commands.pipeline import DockerBuild -from commands.slugify import SlugifyCommand +from challenge_toolkit.commands.challenge_creator import ChallengeCreator +from challenge_toolkit.commands.template_renderer import TemplateRenderer +from challenge_toolkit.commands.page import PageCommand +from challenge_toolkit.commands.pipeline import DockerBuild +from challenge_toolkit.commands.slugify import SlugifyCommand class Args: command = None parser = None - + def __init__(self): self.parser = argparse.ArgumentParser(description="Challenge Toolkit CLI") @@ -19,16 +19,16 @@ def print_help(self): if self.parser: self.parser.print_help() -if __name__ == "__main__": +def main(): try: args = Args() - + if (args.parser is None): print("Error: Parser is not initialized.") exit(1) - + subparser = args.parser.add_subparsers(dest="command", help="Subcommand to run", title="subcommands") - + challengeCreator = ChallengeCreator(subparser) challengeCreator.register_subcommand() templateRenderer = TemplateRenderer(subparser) @@ -43,7 +43,7 @@ def print_help(self): # Get subcommand to run namespace = args.parser.parse_args() command = namespace.command - + # Call the appropriate tool based on the command if command == "create": challengeCreator.run() @@ -62,6 +62,8 @@ def print_help(self): # Detect if we are running inside a Github runner if os.getenv("GITHUB_ACTIONS"): print(f"::error::An error occurred: {e}") - + raise e +if __name__ == "__main__": + main() diff --git a/src/challenge_toolkit/commands/__init__.py b/src/challenge_toolkit/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/challenge_creator.py b/src/challenge_toolkit/commands/challenge_creator.py similarity index 91% rename from src/commands/challenge_creator.py rename to src/challenge_toolkit/commands/challenge_creator.py index 5ed25b9..dfcbe2d 100644 --- a/src/commands/challenge_creator.py +++ b/src/challenge_toolkit/commands/challenge_creator.py @@ -7,22 +7,22 @@ import sys import argparse -from library.config import CHALL_TYPES, DIFFICULTIES, FLAG_FORMAT, INSTANCED_TYPES, CATEGORIES -from library.utils import Utils -from library.data import Challenge, DockerfileLocation -from library.generator import Generator as OSGenerator +from challenge_toolkit.library.config import CHALL_TYPES, DIFFICULTIES, FLAG_FORMAT, INSTANCED_TYPES, CATEGORIES +from challenge_toolkit.library.utils import Utils +from challenge_toolkit.library.data import Challenge, DockerfileLocation +from challenge_toolkit.library.generator import Generator as OSGenerator -class Args: +class Args: args = None subcommand = False - + def __init__(self, parent_parser = None): if parent_parser: self.subcommand = True self.parser = parent_parser.add_parser("create", help="Template Generator for CTF Challenges") else: self.parser = argparse.ArgumentParser(description="Template Generator for CTF Challenges") - + self.parser.add_argument("--no-prompts", help="Skip prompts and use default values", action="store_true") self.parser.add_argument("--name", help="Name of the challenge") self.parser.add_argument("--slug", help="Slug of the challenge") @@ -39,16 +39,16 @@ def __init__(self, parent_parser = None): self.parser.add_argument("--dockerfile-context", help="Context of the Dockerfile", default="src/") self.parser.add_argument("--dockerfile-identifier", help="Identifier of the Dockerfile", default=None) self.parser.add_argument("--handout_location", help="Location of the handout", default="handout") - + def parse(self): if self.subcommand: self.args = self.parser.parse_args(sys.argv[2:]) else: self.args = self.parser.parse_args() - + def prompt(self, challenge: Challenge): args = self.args - + if args is None: # Convert to object if args is None args = self.args = self.parser.parse_args() @@ -63,7 +63,7 @@ def prompt(self, challenge: Challenge): print("Invalid name. Please try again.") else: challenge.set_name(args.name) - + if args.slug is None: while True: try: @@ -73,7 +73,7 @@ def prompt(self, challenge: Challenge): print("Invalid slug. Please try again.") else: challenge.set_slug(args.slug) - + if args.author is None: while True: try: @@ -83,7 +83,7 @@ def prompt(self, challenge: Challenge): print("Invalid author. Please try again.") else: challenge.set_author(args.author) - + if args.category is None: while True: try: @@ -93,7 +93,7 @@ def prompt(self, challenge: Challenge): print("Invalid category. Please try again.") else: challenge.set_category(args.category) - + if args.difficulty is None: while True: try: @@ -103,7 +103,7 @@ def prompt(self, challenge: Challenge): print("Invalid difficulty. Please try again.") else: challenge.set_difficulty(args.difficulty) - + prompted_type = None if args.type is None: while True: @@ -116,7 +116,7 @@ def prompt(self, challenge: Challenge): else: challenge.set_type(args.type) prompted_type = args.type - + if args.flag is None: while True: try: @@ -126,7 +126,7 @@ def prompt(self, challenge: Challenge): print("Invalid flag. Please try again.") else: challenge.set_flag(args.flag) - + if args.points is None: while True: try: @@ -136,7 +136,7 @@ def prompt(self, challenge: Challenge): print("Invalid points. Please try again.") else: challenge.set_points(args.points) - + if args.min_points is None: while True: try: @@ -146,7 +146,7 @@ def prompt(self, challenge: Challenge): print("Invalid minimum points. Please try again.") else: challenge.set_min_points(args.min_points) - + if (args.type in [ "instanced", "shared" ] or prompted_type in [ "instanced", "shared" ]) and args.instanced_type == "none": while True: try: @@ -158,7 +158,7 @@ def prompt(self, challenge: Challenge): challenge.set_instanced_type(args.instanced_type) else: challenge.set_instanced_type("none") - + if args.description_location == "description.md": while True: try: @@ -168,7 +168,7 @@ def prompt(self, challenge: Challenge): print("Invalid description location. Please try again.") else: challenge.set_description_location(args.description_location) - + if args.dockerfile_location is None or args.dockerfile_location == "src/Dockerfile": contains_docker = input("Does the challenge contain a Dockerfile? (y/N): ").lower() == "y" if contains_docker: @@ -177,7 +177,7 @@ def prompt(self, challenge: Challenge): dockerfile_location = input("Location of the Dockerfile (src/Dockerfile): ") or "src/Dockerfile" dockerfile_context = input("Context of the Dockerfile (src/): ") or "src/" dockerfile_identifier = input("Identifier of the Dockerfile: ") or None - + challenge.add_dockerfile_location([ DockerfileLocation(dockerfile_location, dockerfile_context, dockerfile_identifier) ]) break except ValueError: @@ -197,7 +197,7 @@ def __init__(self, challenge: Challenge): self.challenge = challenge self.path = Utils.get_challenge_dir(challenge.category, challenge.slug) self.generator = OSGenerator(challenge) - + def generate(self): self.generator.build() @@ -205,12 +205,12 @@ class ChallengeCreator: args = None parent_parser = None - def __init__(self, parent_parser = None): + def __init__(self, parent_parser = None): self.parent_parser = parent_parser - + def register_subcommand(self): self.args = Args(self.parent_parser) - + def run(self): if not self.args: arguments = Args(self.parent_parser) @@ -218,48 +218,48 @@ def run(self): self.args = arguments else: self.args.parse() - + arguments = self.args args = self.args.args - + if not args: print("Error parsing arguments. Please run with --help to see available options.") sys.exit(1) - + if args.name and args.slug is None: args.slug = Utils.slugify(args.name) if args.name else "challenge" - + challenge = None if args.no_prompts == False: challenge = Challenge(name="demo", slug="demo", author="demo", category="misc", difficulty="easy", type="static", flag="flag{demo_flag}") arguments.prompt(challenge) print("\nInformation filled out.") - + print("\nInformation for the challenge:") print(challenge) - + print("\nIs the information correct?") if (input("Y/n: ") or "y").lower() != "y": print("Exiting...") sys.exit(1) - + else: challenge = Challenge( - name = args.name, - slug = args.slug, - author = args.author, - category = args.category, - difficulty = args.difficulty, + name = args.name, + slug = args.slug, + author = args.author, + category = args.category, + difficulty = args.difficulty, type = args.type, - instanced_type = args.instanced_type or "none", - flag = args.flag, - points = args.points or 1000, + instanced_type = args.instanced_type or "none", + flag = args.flag, + points = args.points or 1000, min_points = args.min_points or 100, description_location = args.description_location, handout_dir = args.handout_location ) - + if args.type != "static": try: if args.dockerfile_location: @@ -269,8 +269,7 @@ def run(self): generator = Generator(challenge) generator.generate() - + if __name__ == '__main__': ChallengeCreator().run() - diff --git a/src/commands/page.py b/src/challenge_toolkit/commands/page.py similarity index 94% rename from src/commands/page.py rename to src/challenge_toolkit/commands/page.py index f81291f..fffd017 100644 --- a/src/commands/page.py +++ b/src/challenge_toolkit/commands/page.py @@ -4,17 +4,17 @@ from datetime import datetime -from library.utils import Utils -from library.data import Page -from library.generator import Generator -from library.config import PAGE_SCHEMA +from challenge_toolkit.library.utils import Utils +from challenge_toolkit.library.data import Page +from challenge_toolkit.library.generator import Generator +from challenge_toolkit.library.config import PAGE_SCHEMA class Args: args = None page: Page subcommand = False repo: str - + def __init__(self, parent_parser = None): if parent_parser: self.subcommand = True @@ -24,13 +24,13 @@ def __init__(self, parent_parser = None): self.parser.add_argument("page", help="Page to render (directory for page - 'web/example')") self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY", "")) - + def parse(self): if self.subcommand: self.args = self.parser.parse_args(sys.argv[2:]) else: self.args = self.parser.parse_args() - + # Parse page from page argument page_path = Utils.get_page_dir(self.args.page) if not page_path.exists() or not page_path.is_dir(): @@ -45,7 +45,7 @@ def parse(self): self.page = page self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "") - + if not self.repo or self.repo.strip() == "": print("GitHub repository is required. Please provide it via the --repo argument or the GITHUB_REPOSITORY environment variable.") sys.exit(1) @@ -68,7 +68,7 @@ def run(self): if not self.page: print("No page specified") sys.exit(1) - + @staticmethod def replace_templated(key: str, value: str, content: str): content = content.replace("{{ " + key + " }}", value) @@ -76,15 +76,15 @@ def replace_templated(key: str, value: str, content: str): content = content.replace("{ { " + key + " } }", value) content = content.replace("{ {" + key + "} }", value) return content - + def get_template_content(self): template_source = self.page.str_json(PAGE_SCHEMA) # Iterate over each line in the source, and indent it template_source_indented = "".join([" " + line + "\n" for line in template_source.splitlines()]) - + return template_source_indented - + def get_content(self): if not self.page: print("No page specified") @@ -92,24 +92,24 @@ def get_content(self): # Get the content of the page content = self.page.content - + # Check if file exists page_path = Utils.get_page_dir(self.page.slug) content_path = page_path.joinpath(content) - + print(f"Rendering content from {content_path}") - + if not content_path.exists(): print(f"Content file {content} does not exist in page {self.page.slug}") sys.exit(1) with open(content_path, "r") as f: rendered_content = f.read() - + # Iterate over each line in the source, and indent it rendered_content_indented = "".join([" " + line + "\n" for line in rendered_content.splitlines()]) - + return rendered_content_indented - + def render(self, args: Args): if not os.path.exists(Utils.get_template_dir()) or not os.path.isdir(Utils.get_template_dir()) or not os.path.exists(os.path.join(Utils.get_template_dir(), self.configmap_template)): print("Configmap template source file does not exist. Critical error.") @@ -122,7 +122,7 @@ def render(self, args: Args): version += 1 self.page.save_version(version) print(f"New version: {version}") - + # Get template content template = os.path.join(Utils.get_template_dir(), self.configmap_template) template_content = "" @@ -132,10 +132,10 @@ def render(self, args: Args): # Insert template content template_json = self.get_template_content() output_content_initial = template_content.replace(" %%PAGE%%", template_json) - + content = self.get_content() output_content = output_content_initial.replace(" %%CONTENT%%", content) - + # Template values in configmap output_content = self.replace_templated("PAGE_SLUG", self.page.slug, output_content) output_content = self.replace_templated("PAGE_NAME", self.page.slug, output_content) @@ -143,20 +143,20 @@ def render(self, args: Args): output_content = self.replace_templated("PAGE_REPO", args.repo, output_content) output_content = self.replace_templated("PAGE_VERSION", str(args.page.get_version()), output_content) output_content = self.replace_templated("PAGE_ENABLED", str(args.page.enabled).lower(), output_content) - + # Insert the current date, for knowing when the challenge was last updated now = datetime.now() current_date = now.strftime("%Y-%m-%d %H:%M:%S") output_content = self.replace_templated("CURRENT_DATE", current_date, output_content) - + # Write the output to a file output_file = os.path.join(Utils.get_k8s_page_dir(args.page.slug), f"page.yml") if not os.path.exists(os.path.dirname(output_file)): - os.makedirs(os.path.dirname(output_file)) - + os.makedirs(os.path.dirname(output_file)) + with open(output_file, "w") as f: f.write(output_content) - + print(f"Configmap generated at {output_file}") class PageCommand: @@ -165,10 +165,10 @@ class PageCommand: def __init__(self, parent_parser = None): self.parent_parser = parent_parser - + def register_subcommand(self): self.args = Args(self.parent_parser) - + def run(self): if not self.args: arguments = Args(self.parent_parser) @@ -176,9 +176,9 @@ def run(self): self.args = arguments else: self.args.parse() - + args = self.args - + if not args or not args.page: print("No page specified") return @@ -186,4 +186,4 @@ def run(self): PageRender(args.page).render(args) if __name__ == "__main__": - PageCommand().run() \ No newline at end of file + PageCommand().run() diff --git a/src/commands/pipeline.py b/src/challenge_toolkit/commands/pipeline.py similarity index 95% rename from src/commands/pipeline.py rename to src/challenge_toolkit/commands/pipeline.py index 196a077..4491aab 100644 --- a/src/commands/pipeline.py +++ b/src/challenge_toolkit/commands/pipeline.py @@ -2,25 +2,25 @@ import argparse import subprocess -from library.utils import Utils -from library.data import Challenge, DockerfileLocation +from challenge_toolkit.library.utils import Utils +from challenge_toolkit.library.data import Challenge, DockerfileLocation class Args: args = None subcommand = False - + def __init__(self, parent_parser = None): if parent_parser: self.subcommand = True self.parser = parent_parser.add_parser("pipeline", help="Pipeline for CTF challenges") else: self.parser = argparse.ArgumentParser(description="Pipeline for CTF challenges") - + self.parser.add_argument("challenge", help="Challenge to run (directory for challenge - 'web/example')") self.parser.add_argument("registry", help="Registry to push the Docker image to") self.parser.add_argument("image_prefix", help="Prefix for the Docker image") self.parser.add_argument("--image_suffix", help="Suffix for the Docker image", default="") - + def parse(self): if self.subcommand: self.args = self.parser.parse_args(sys.argv[2:]) @@ -29,26 +29,26 @@ def parse(self): def __getattr__(self, name): return getattr(self.args, name) - + class Docker: def __init__(self, registry: str, image_prefix: str, image_suffix: str): self.registry = registry self.image_prefix = image_prefix self.image_suffix = image_suffix - + @staticmethod def build(registry: str, image_prefix: str, image_suffix: str, challenge: Challenge, dockerfile_location: DockerfileLocation): image_full = f"{registry}/{image_prefix}-{Utils.slugify(challenge.category)}-{challenge.slug}".lower() - + if dockerfile_location.identifier and dockerfile_location.identifier.lower() not in ["none", "null", ""]: image_full += f"-{dockerfile_location.identifier}" if image_suffix and image_suffix.lower() not in ["none", "null", ""]: image_full += f"-{image_suffix}" - + image_full = image_full.lower() - + print(f"Building Docker image \"{image_full}\"...") - + try: build_command = [ "docker", "build", @@ -83,12 +83,12 @@ class DockerBuild: args = None parent_parser = None - def __init__(self, parent_parser = None): + def __init__(self, parent_parser = None): self.parent_parser = parent_parser - + def register_subcommand(self): self.args = Args(self.parent_parser) - + def run(self): if not self.args: arguments = Args(self.parent_parser) @@ -96,19 +96,19 @@ def run(self): self.args = arguments else: self.args.parse() - + args = self.args.args - + if not args: print("No arguments provided") sys.exit(1) - + challenge = args.challenge - + if "/" not in challenge: print(f"Challenge {challenge} must be in the format 'category/name'") exit(1) - + challenge_path = Utils.get_challenges_dir().joinpath(challenge) if not challenge_path.exists(): print(f"Challenge {challenge} does not exist") @@ -119,19 +119,19 @@ def run(self): sys.exit(1) print(f"Running pipeline for challenge \"{challenge}\"") - + print("Loading challenge data...") challenge = Challenge.load_dir(challenge_path) if not challenge: print("Failed to load challenge data") sys.exit(1) - + print("Data loaded successfully") print("") print("Data:") print(challenge) print("") - + version = challenge.get_version() print(f"Current version: {version}") print("Incrementing version...") @@ -148,7 +148,6 @@ def run(self): Docker.build(docker.registry, docker.image_prefix, docker.image_suffix, challenge, dockerfile_location) print("Docker process complete") - + if __name__ == "__main__": DockerBuild().run() - diff --git a/src/commands/slugify.py b/src/challenge_toolkit/commands/slugify.py similarity index 95% rename from src/commands/slugify.py rename to src/challenge_toolkit/commands/slugify.py index 2793b80..eed756d 100644 --- a/src/commands/slugify.py +++ b/src/challenge_toolkit/commands/slugify.py @@ -1,13 +1,13 @@ import sys import argparse -from library.utils import Utils +from challenge_toolkit.library.utils import Utils class Args: args = None slug: str = "" subcommand = False - + def __init__(self, parent_parser = None): if parent_parser: self.subcommand = True @@ -16,19 +16,19 @@ def __init__(self, parent_parser = None): self.parser = argparse.ArgumentParser(description="Slugify a string for use in challenge slug") self.parser.add_argument("name", help="Name to slugify") - + def parse(self): if self.subcommand: self.args = self.parser.parse_args(sys.argv[2:]) else: self.args = self.parser.parse_args() - + if not self.args.name: print("Name is required") sys.exit(1) self.slug = self.args.name - + class Slugify: @staticmethod def run(name: str) -> str: @@ -43,10 +43,10 @@ class SlugifyCommand: def __init__(self, parent_parser = None): self.parent_parser = parent_parser - + def register_subcommand(self): self.args = Args(self.parent_parser) - + def run(self): if not self.args: arguments = Args(self.parent_parser) @@ -54,8 +54,7 @@ def run(self): self.args = arguments else: self.args.parse() - + args = self.args print(Slugify.run(args.slug)) - diff --git a/src/commands/template_renderer.py b/src/challenge_toolkit/commands/template_renderer.py similarity index 95% rename from src/commands/template_renderer.py rename to src/challenge_toolkit/commands/template_renderer.py index 2ae4d41..a4e959d 100644 --- a/src/commands/template_renderer.py +++ b/src/challenge_toolkit/commands/template_renderer.py @@ -7,10 +7,10 @@ from datetime import datetime -from library.utils import Utils -from library.data import Challenge -from library.generator import Generator -from library.config import CHALLENGE_SCHEMA +from challenge_toolkit.library.utils import Utils +from challenge_toolkit.library.data import Challenge +from challenge_toolkit.library.generator import Generator +from challenge_toolkit.library.config import CHALLENGE_SCHEMA class Args: args = None @@ -19,61 +19,61 @@ class Args: expires: int = 3600 available: int = 0 repo: str - + def __init__(self, parent_parser = None): if parent_parser: self.subcommand = True self.parser = parent_parser.add_parser("template", help="Render template for K8s challenge") else: self.parser = argparse.ArgumentParser(description="Render template for K8s challenge") - + self.parser.add_argument("renderer", help="Renderer to use for the challenge", choices=["k8s", "configmap", "clean", "handout"]) self.parser.add_argument("challenge", help="Challenge to run (directory for challenge - 'web/example')") self.parser.add_argument("--expires", help="Time until challenge expires", type=int, default=3600) self.parser.add_argument("--available", help="Time until challenge is available", type=int, default=0) self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY", "")) - + def parse(self): if self.subcommand: self.args = self.parser.parse_args(sys.argv[2:]) else: self.args = self.parser.parse_args() - + # Parse challenge from challenge argument challenge_path = Utils.get_challenges_dir().joinpath(self.args.challenge) if not challenge_path.exists() or not challenge_path.is_dir(): print(f"Challenge {self.args.challenge} does not exist") sys.exit(1) - + challenge = Challenge.load_dir(challenge_path) - + if not challenge: print(f"Challenge {self.args.challenge} is not a valid challenge") sys.exit(1) - + self.challenge = challenge - + self.expires = self.args.expires self.available = self.args.available self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "") - + if not self.repo or self.repo.strip() == "": print("GitHub repository is required. Please provide it via the --repo argument or the GITHUB_REPOSITORY environment variable.") sys.exit(1) - + def __getattr__(self, name): return getattr(self.args, name) class Clean: def __init__(self, challenge: Challenge): self.challenge = challenge - + def run(self): path = Utils.get_k8s_dir(self.challenge.category, self.challenge.slug) if not os.path.exists(path): print(f"Challenge {self.challenge.slug} does not have a k8s directory.") return - + # Empty the k8s directory for root, dirs, files in os.walk(path, topdown=False): for name in files: @@ -90,7 +90,7 @@ def run(self): print(f"Removed directory: {dir_path}") except Exception as e: print(f"Error removing directory {dir_path}: {e}") - + print(f"Cleaned instanced template for {self.challenge.slug}") class Renderer: @@ -101,7 +101,7 @@ def replace_templated(key: str, value: str, content: str): content = content.replace("{ { " + key + " } }", value) content = content.replace("{ {" + key + "} }", value) return content - + class K8s: def __init__(self, challenge: Challenge): @@ -112,32 +112,32 @@ def get_template_content(self): template_source_path = os.path.join(Utils.get_template_dir(), "instanced-k8s-challenge.yml") with open(template_source_path, "r") as f: base_template_content = f.read() - + challenge_template = os.path.join(self.generator.dir_template, "k8s.yml") challenge_template_content = "" challenge_template_indented = "" with open(challenge_template, "r") as f: challenge_template_content = f.read() challenge_template_indented = "\n".join([" " + line for line in challenge_template_content.splitlines()]) - + return base_template_content, challenge_template_content, challenge_template_indented def render(self, args: Args): if not self.generator.instanced_template_source_file_exists(): print("Instanced template source file does not exist. Critical error.") sys.exit(1) - + if not self.generator.instanced_template_file_exists(): print("Challenge does not have a k8s template.") sys.exit(0) - + base_template_content, challenge_template, challenge_template_indented = self.get_template_content() # If instanced, it needs to utilize the base template for instanced challenges templateing_base_template = challenge_template if self.challenge.type == "instanced": templateing_base_template = base_template_content - + print(f"Rendering k8s template for challenge {args.challenge.slug}...") output_content = templateing_base_template.replace(" %%TEMPLATE%%", challenge_template_indented) @@ -149,7 +149,7 @@ def render(self, args: Args): output_content = Renderer.replace_templated("CHALLENGE_EXPIRES", str(args.expires), output_content) output_content = Renderer.replace_templated("CHALLENGE_AVAILABLE_AT", str(args.available), output_content) output_content = Renderer.replace_templated("CHALLENGE_REPO", args.repo, output_content) - + # Create docker image name docker_image = f"{args.challenge.category}-{args.challenge.slug}".lower().replace(" ", "") output_content = Renderer.replace_templated("DOCKER_IMAGE", docker_image, output_content) @@ -169,7 +169,7 @@ def render(self, args: Args): f.write(f"description: Challenge {args.challenge.slug} in category {args.challenge.category}\n") f.write(f"appVersion: \"{semver_version}\"\n") f.write(f"type: application\n") - + helm_values_file = os.path.join(deployment_dir, "values.yaml") with open(helm_values_file, "w") as f: f.write(f"challenge:\n") @@ -189,11 +189,11 @@ def render(self, args: Args): output_file = os.path.join(deployment_dir, "k8s.yml") if not os.path.exists(os.path.dirname(output_file)): - os.makedirs(os.path.dirname(output_file)) + os.makedirs(os.path.dirname(output_file)) with open(output_file, "w") as f: f.write(output_content) - + print(f"K8s template generated at {output_file}") class ConfigMap: @@ -201,26 +201,26 @@ class ConfigMap: Generate configmap for k8s, which contains the challenge json file ''' configmap_template = "challenge-configmap.yml" - + def __init__(self, challenge: Challenge): self.challenge = challenge - + def get_template_content(self): template_source = self.challenge.str_json(CHALLENGE_SCHEMA) - + # Iterate over each line in the source, and indent it template_source_indented = "".join([" " + line + "\n" for line in template_source.splitlines()]) - + return template_source_indented - + def get_description(self): return "".join([" " + line + "\n" for line in self.challenge.get_description().splitlines()]) - + def render(self, args: Args): if not os.path.exists(Utils.get_template_dir()) or not os.path.isdir(Utils.get_template_dir()) or not os.path.exists(os.path.join(Utils.get_template_dir(), self.configmap_template)): print("Configmap template source file does not exist. Critical error.") sys.exit(1) - + template = os.path.join(Utils.get_template_dir(), self.configmap_template) template_content = "" with open(template, "r") as f: @@ -230,7 +230,7 @@ def render(self, args: Args): template_json = self.get_template_content() output_content = template_content.replace(" %%CONFIG%%", template_json) output_content = output_content.replace(" %%DESCRIPTION%%", self.get_description()) - + # Template values in configmap output_content = Renderer.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) output_content = Renderer.replace_templated("CHALLENGE_PATH", Utils.get_challenge_dir_str(self.challenge.category, self.challenge.slug), output_content) @@ -240,7 +240,7 @@ def render(self, args: Args): output_content = Renderer.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) output_content = Renderer.replace_templated("CHALLENGE_ENABLED", str(args.challenge.enabled).lower(), output_content) output_content = Renderer.replace_templated("HOST", "{{ .Values.kubectf.host }}", output_content) - + # Insert the current date, for knowing when the challenge was last updated now = datetime.now() current_date = now.strftime("%Y-%m-%d %H:%M:%S") @@ -249,7 +249,7 @@ def render(self, args: Args): configmap_dir =Utils.get_configmap_dir(args.challenge.category, args.challenge.slug) if not os.path.exists(configmap_dir): os.makedirs(configmap_dir) - + helm_template = os.path.join(configmap_dir, "Chart.yaml") with open(helm_template, "w") as f: f.write("apiVersion: v2\n") @@ -259,7 +259,7 @@ def render(self, args: Args): f.write(f"description: Challenge configmap for {args.challenge.slug} in category {args.challenge.category}\n") f.write(f"appVersion: \"{semver_version}\"\n") f.write(f"type: application\n") - + helm_values_file = os.path.join(configmap_dir, "values.yaml") with open(helm_values_file, "w") as f: f.write(f"challenge:\n") @@ -273,27 +273,27 @@ def render(self, args: Args): f.write(f" expires: {args.expires}\n") f.write(f" availableAt: {args.available}\n") f.write(f" host: example.com\n") - + configmap_dir = os.path.join(configmap_dir, "templates") output_file = os.path.join(configmap_dir, "k8s.yml") if not os.path.exists(os.path.dirname(output_file)): - os.makedirs(os.path.dirname(output_file)) - + os.makedirs(os.path.dirname(output_file)) + with open(output_file, "w") as f: f.write(output_content) - + print(f"Configmap generated at {output_file}") class HandoutRenderer: def __init__(self, challenge: Challenge): self.challenge = challenge - + def render(self): print(f"Rendering handout for challenge {self.challenge.slug}...") - + # Copy the handout directory to a temporary location challenge_path = Utils.get_challenge_dir(self.challenge.category, self.challenge.slug) - + # Check if the file directory exists path = Utils.get_k8s_dir(self.challenge.category, self.challenge.slug) files_path = os.path.join(path, "files") @@ -317,13 +317,13 @@ def render(self): print(f"Handout directory {handout_dir} does not exist for challenge {self.challenge.slug}.") print("Please create the handout directory and add the necessary files, if you want to pack handout files.") sys.exit(0) - + # Create temporary directory for handout with tempfile.TemporaryDirectory() as temp_dir: # Create structure of //handout temp_handout_path = os.path.join(temp_dir, f"{self.challenge.category}_{self.challenge.slug}") os.makedirs(temp_handout_path, exist_ok=True) - + # Copy files from the handout directory to the temporary directory handout_base = Path(handout_path).resolve() for item in os.listdir(handout_path): @@ -336,28 +336,28 @@ def render(self): except (ValueError, RuntimeError): print(f"Skipping item {item} as it is outside the handout directory.") continue - + source_item = str(source_item_resolved) dest_item = os.path.join(temp_handout_path, item) - + # Get filename item_filename = os.path.basename(item) if item_filename in ['.gitkeep', '.gitignore']: # Skip .gitkeep and .gitignore files continue - + if os.path.isdir(source_item): # Copy directory - shutil.copytree(source_item, dest_item, dirs_exist_ok=True) - else: - # Copy file - shutil.copy2(source_item, dest_item) - + shutil.copytree(source_item, dest_item, dirs_exist_ok=True) + else: + # Copy file + shutil.copy2(source_item, dest_item) + # If no files are present in the handout directory, do not create a zip file if not os.listdir(temp_handout_path): print("No files found in the handout directory. Skipping zip creation.") return - + # Create a zip file of the handout directory handout_zip_path = os.path.join(files_path, f"{self.challenge.category}_{self.challenge.slug}") shutil.make_archive(handout_zip_path, 'zip', root_dir=temp_dir, base_dir=f"{self.challenge.category}_{self.challenge.slug}") @@ -371,10 +371,10 @@ class TemplateRenderer: def __init__(self, parent_parser = None): self.parent_parser = parent_parser - + def register_subcommand(self): self.args = Args(self.parent_parser) - + def run(self): if not self.args: arguments = Args(self.parent_parser) @@ -382,9 +382,9 @@ def run(self): self.args = arguments else: self.args.parse() - + args = self.args - + if args.renderer == "clean": clean = Clean(args.challenge) clean.run() diff --git a/src/challenge_toolkit/library/__init__.py b/src/challenge_toolkit/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/library/config.py b/src/challenge_toolkit/library/config.py similarity index 100% rename from src/library/config.py rename to src/challenge_toolkit/library/config.py diff --git a/src/library/data.py b/src/challenge_toolkit/library/data.py similarity index 100% rename from src/library/data.py rename to src/challenge_toolkit/library/data.py diff --git a/src/library/generator.py b/src/challenge_toolkit/library/generator.py similarity index 100% rename from src/library/generator.py rename to src/challenge_toolkit/library/generator.py diff --git a/src/library/utils.py b/src/challenge_toolkit/library/utils.py similarity index 100% rename from src/library/utils.py rename to src/challenge_toolkit/library/utils.py diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index 9d275bf..0000000 --- a/src/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pyyaml==6.0.2 -python-slugify==8.0.4 diff --git a/src/test.py b/src/test.py deleted file mode 100644 index 93aef1f..0000000 --- a/src/test.py +++ /dev/null @@ -1,10 +0,0 @@ -import unittest -import io -from contextlib import redirect_stdout - -from tests.library.dataTest import TestChallenge, TestChallengeFileLoad, TestChallengeFileWrite, TestPage - -if __name__ == '__main__': - - with io.StringIO() as buf, redirect_stdout(buf): - unittest.main() diff --git a/src/tests/data/full-example-multi-flag-object.json b/tests/data/full-example-multi-flag-object.json similarity index 100% rename from src/tests/data/full-example-multi-flag-object.json rename to tests/data/full-example-multi-flag-object.json diff --git a/src/tests/data/full-example-multi-flag-object.yml b/tests/data/full-example-multi-flag-object.yml similarity index 100% rename from src/tests/data/full-example-multi-flag-object.yml rename to tests/data/full-example-multi-flag-object.yml diff --git a/src/tests/data/full-example-multi-flag.json b/tests/data/full-example-multi-flag.json similarity index 100% rename from src/tests/data/full-example-multi-flag.json rename to tests/data/full-example-multi-flag.json diff --git a/src/tests/data/full-example-multi-flag.yml b/tests/data/full-example-multi-flag.yml similarity index 100% rename from src/tests/data/full-example-multi-flag.yml rename to tests/data/full-example-multi-flag.yml diff --git a/src/tests/data/full-example.json b/tests/data/full-example.json similarity index 100% rename from src/tests/data/full-example.json rename to tests/data/full-example.json diff --git a/src/tests/data/full-example.yaml b/tests/data/full-example.yaml similarity index 100% rename from src/tests/data/full-example.yaml rename to tests/data/full-example.yaml diff --git a/src/tests/data/full-example.yml b/tests/data/full-example.yml similarity index 100% rename from src/tests/data/full-example.yml rename to tests/data/full-example.yml diff --git a/src/tests/data/minimal-example.yml b/tests/data/minimal-example.yml similarity index 100% rename from src/tests/data/minimal-example.yml rename to tests/data/minimal-example.yml diff --git a/src/tests/library/dataTest.py b/tests/test_data.py similarity index 99% rename from src/tests/library/dataTest.py rename to tests/test_data.py index 89be844..29601ae 100644 --- a/src/tests/library/dataTest.py +++ b/tests/test_data.py @@ -1,10 +1,11 @@ import unittest import sys import json +import pathlib sys.path.append('..') -from library.data import DockerfileLocation, Challenge, ChallengeFlag, Page +from challenge_toolkit.library.data import DockerfileLocation, Challenge, ChallengeFlag, Page class TestChallenge(unittest.TestCase): def setUp(self): @@ -61,7 +62,7 @@ def test_setters(self): self.challenge.set_difficulty("medium") self.assertEqual(self.challenge.difficulty, "medium") - + self.challenge.set_tags(["new", "tags"]) self.assertEqual(self.challenge.tags, ["new", "tags"]) @@ -70,13 +71,13 @@ def test_setters(self): self.challenge.set_instanced_type("tcp") self.assertEqual(self.challenge.instanced_type, "tcp") - + self.challenge.set_instanced_name("new-instanced-challenge") self.assertEqual(self.challenge.instanced_name, "new-instanced-challenge") - + self.challenge.set_instanced_subdomains(["new-demo"]) self.assertEqual(self.challenge.instanced_subdomains, ["new-demo"]) - + self.challenge.set_connection("nc new.example.com 4444") self.assertEqual(self.challenge.connection, "nc new.example.com 4444") @@ -85,7 +86,7 @@ def test_setters(self): self.challenge.set_points(1000) self.assertEqual(self.challenge.points, 1000) - + self.challenge.set_decay(50) self.assertEqual(self.challenge.decay, 50) @@ -217,7 +218,7 @@ def test_missing_type(self): description_location="description.md", handout_dir="files" ) - + def test_too_long_connection(self): with self.assertRaises(ValueError): Challenge( @@ -379,7 +380,7 @@ def test_bad_type(self): description_location="description.md", handout_dir="files" ) - + def test_bad_tags(self): with self.assertRaises(ValueError): Challenge( @@ -416,7 +417,7 @@ def test_bad_flag(self): description_location="description.md", handout_dir="files" ) - + def test_single_flag(self): challenge = Challenge( enabled=True, @@ -488,7 +489,7 @@ def test_bad_min_points(self): description_location="description.md", handout_dir="files" ) - + def test_missing_decay(self): Challenge( enabled=True, @@ -560,7 +561,7 @@ def test_bad_handout_dir(self): description_location="description.md", handout_dir="invalid/files/dir!" ) - + def test_bad_instanced_subdomains(self): with self.assertRaises(ValueError): Challenge( @@ -618,7 +619,7 @@ def test_str_json_output(self): self.assertEqual(data["min_points"], 50) self.assertEqual(data["description_location"], "description.md") self.assertEqual(data["handout_dir"], "files") - + def test_str_json_output_multiple_flags(self): schema_url = "http://example.com/schema.json" challenge = Challenge( @@ -654,7 +655,7 @@ def test_str_json_output_multiple_flags(self): self.assertEqual(data["min_points"], 50) self.assertEqual(data["description_location"], "description.md") self.assertEqual(data["handout_dir"], "files") - + def test_str_json_output_multiple_flag_objects(self): schema_url = "http://example.com/schema.json" challenge = Challenge( @@ -690,7 +691,7 @@ def test_str_json_output_multiple_flag_objects(self): self.assertEqual(data["min_points"], 50) self.assertEqual(data["description_location"], "description.md") self.assertEqual(data["handout_dir"], "files") - + def test_str_json_output_special_chars(self): schema_url = "http://example.com/schema.json" challenge = Challenge( @@ -768,7 +769,7 @@ def test_str_yml_output(self): self.assertIn("min_points: 50", yml_str) self.assertIn("description_location: description.md", yml_str) self.assertIn("handout_dir: files", yml_str) - + def test_str_yml_output_multiple_flags(self): schema_url = "http://example.com/schema.json" challenge = Challenge( @@ -806,7 +807,7 @@ def test_str_yml_output_multiple_flags(self): self.assertIn("min_points: 50", yml_str) self.assertIn("description_location: description.md", yml_str) self.assertIn("handout_dir: files", yml_str) - + def test_str_yml_output_multiple_flag_objects(self): schema_url = "http://example.com/schema.json" challenge = Challenge( @@ -846,7 +847,7 @@ def test_str_yml_output_multiple_flag_objects(self): self.assertIn("handout_dir: files", yml_str) class TestChallengeFileLoad(unittest.TestCase): - file_dir = 'tests/data' + file_dir = pathlib.Path(__file__).parent.joinpath('data') json_file = 'full-example.json' json_multi_flag_file = 'full-example-multi-flag.json' json_multi_flag_object_file = 'full-example-multi-flag-object.json' @@ -855,7 +856,7 @@ class TestChallengeFileLoad(unittest.TestCase): yml_multi_flag_object_file = 'full-example-multi-flag-object.yml' yaml_file = 'full-example.yaml' minimal_example_file = 'minimal-example.yml' - + def test_load_json(self): challenge = Challenge.load(f'{self.file_dir}/{self.json_file}') self.assertEqual(challenge.name, "Example Challenge") @@ -873,7 +874,7 @@ def test_load_json(self): self.assertEqual(challenge.min_points, 50) self.assertEqual(challenge.description_location, "demo/description.md") self.assertEqual(challenge.handout_dir, "handouts") - + def test_load_json_multi_flag(self): challenge = Challenge.load(f'{self.file_dir}/{self.json_multi_flag_file}') self.assertEqual(challenge.name, "Example Challenge Multi Flag") @@ -891,7 +892,7 @@ def test_load_json_multi_flag(self): self.assertEqual(challenge.min_points, 100) self.assertEqual(challenge.description_location, "demo/description.md") self.assertEqual(challenge.handout_dir, "handouts") - + def test_load_json_multi_flag_object(self): challenge = Challenge.load(f'{self.file_dir}/{self.json_multi_flag_object_file}') self.assertEqual(challenge.name, "Example Challenge Multi Flag") @@ -909,7 +910,7 @@ def test_load_json_multi_flag_object(self): self.assertEqual(challenge.min_points, 100) self.assertEqual(challenge.description_location, "demo/description.md") self.assertEqual(challenge.handout_dir, "handouts") - + def test_load_yml(self): challenge = Challenge.load(f'{self.file_dir}/{self.yml_file}') self.assertEqual(challenge.name, "Example Challenge") @@ -946,7 +947,7 @@ def test_load_yml_multi_flag(self): self.assertEqual(challenge.min_points, 100) self.assertEqual(challenge.description_location, "demo/description.md") self.assertEqual(challenge.handout_dir, "handouts") - + def test_load_yml_multi_flag_object(self): challenge = Challenge.load(f'{self.file_dir}/{self.yml_multi_flag_object_file}') self.assertEqual(challenge.name, "Example Challenge Multi Flag") @@ -982,7 +983,7 @@ def test_load_yaml(self): self.assertEqual(challenge.min_points, 50) self.assertEqual(challenge.description_location, "demo/description.md") self.assertEqual(challenge.handout_dir, "handouts") - + def test_load_minimal_example(self): challenge = Challenge.load(f'{self.file_dir}/{self.minimal_example_file}') self.assertEqual(challenge.name, "Example Challenge") @@ -1004,7 +1005,7 @@ def test_load_minimal_example(self): def test_bad_file(self): with self.assertRaises(FileNotFoundError): Challenge.load(f'{self.file_dir}/invalid_challenge.json') - + def test_bad_file_extension(self): with self.assertRaises(ValueError): Challenge.load(f'{self.file_dir}/invalid_challenge.txt') @@ -1185,4 +1186,4 @@ def test_page_load_from_yaml(self): self.assertFalse(page.draft) if __name__ == '__main__': - print("Tests cannot be run directly. Please run test.py") \ No newline at end of file + print("Tests cannot be run directly. Please run test.py") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..56d012c --- /dev/null +++ b/uv.lock @@ -0,0 +1,229 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "challenge-toolkit" +version = "1.1.3.dev0" +source = { editable = "." } +dependencies = [ + { name = "python-slugify" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "python-slugify", specifier = "==8.0.4" }, + { name = "pyyaml", specifier = "==6.0.2" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]