From 7738a51d16c7529c6ae598e390caae729182f1e0 Mon Sep 17 00:00:00 2001 From: k3nny Date: Mon, 29 Jun 2026 14:22:37 +0200 Subject: [PATCH] feat(apim-apps): first commit --- .cicd/.releaserc.json | 19 +++ .cicd/deploy.yml | 179 ++++++++++++++++++++++++++++ .cicd/globals.yml | 27 +++++ .cicd/lint.yml | 24 ++++ .cicd/release.yml | 27 +++++ .gitignore | 14 +++ .gitlab-ci.yml | 17 +++ .python-version | 1 + README.md | 166 ++++++++++++++++++++++++++ apim_apps.py | 5 + apim_apps/__init__.py | 5 + apim_apps/_colors.py | 16 +++ apim_apps/cli.py | 110 +++++++++++++++++ apim_apps/client.py | 122 +++++++++++++++++++ apim_apps/engine.py | 173 +++++++++++++++++++++++++++ apim_apps/loader.py | 125 ++++++++++++++++++++ apim_apps/models.py | 60 ++++++++++ apim_apps/report.py | 52 +++++++++ apps.yaml | 54 +++++++++ config.json | 24 ++++ pyproject.toml | 20 ++++ tests/__init__.py | 0 tests/test_append_only.py | 40 +++++++ tests/test_cli.py | 116 ++++++++++++++++++ tests/test_config.py | 43 +++++++ tests/test_manifest.py | 96 +++++++++++++++ tests/test_normalize.py | 198 +++++++++++++++++++++++++++++++ uv.lock | 240 ++++++++++++++++++++++++++++++++++++++ 28 files changed, 1973 insertions(+) create mode 100644 .cicd/.releaserc.json create mode 100644 .cicd/deploy.yml create mode 100644 .cicd/globals.yml create mode 100644 .cicd/lint.yml create mode 100644 .cicd/release.yml create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .python-version create mode 100644 README.md create mode 100644 apim_apps.py create mode 100644 apim_apps/__init__.py create mode 100644 apim_apps/_colors.py create mode 100644 apim_apps/cli.py create mode 100644 apim_apps/client.py create mode 100644 apim_apps/engine.py create mode 100644 apim_apps/loader.py create mode 100644 apim_apps/models.py create mode 100644 apim_apps/report.py create mode 100644 apps.yaml create mode 100644 config.json create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_append_only.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_manifest.py create mode 100644 tests/test_normalize.py create mode 100644 uv.lock diff --git a/.cicd/.releaserc.json b/.cicd/.releaserc.json new file mode 100644 index 0000000..0374686 --- /dev/null +++ b/.cicd/.releaserc.json @@ -0,0 +1,19 @@ +{ + "branches": ["main"], + "tagFormat": "${version}", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/changelog", { + "changelogFile": "CHANGELOG.md" + }], + ["@semantic-release/git", { + "assets": ["CHANGELOG.md"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + }], + ["@semantic-release/gitlab", { + "gitlabUrl": "${CI_SERVER_URL}", + "gitlabApiPathPrefix": "/api/v4" + }] + ] +} diff --git a/.cicd/deploy.yml b/.cicd/deploy.yml new file mode 100644 index 0000000..5e39665 --- /dev/null +++ b/.cicd/deploy.yml @@ -0,0 +1,179 @@ +# ── fix/* and feat/* branches — DEV only ───────────────────────────────────── +# +# dry-run runs automatically after lints pass. +# apply is manual and requires dry-run to succeed first. + +dry-run:dev:branch: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_BRANCH =~ /^(fix|feat)\/.+/ + variables: + APIM_ENV: DEV + needs: + - lint:config-json + - lint:apps-yaml + environment: + name: DEV + action: prepare + +apply:dev:branch: + extends: .apply-base + rules: + - if: $CI_COMMIT_BRANCH =~ /^(fix|feat)\/.+/ + when: manual + variables: + APIM_ENV: DEV + needs: + - dry-run:dev:branch + environment: + name: DEV + +# ── Tags — all environments (dry-run and apply are both manual) ─────────────── +# +# Intended workflow per environment: +# 1. Trigger dry-run manually to verify what will change. +# 2. Once dry-run passes, apply becomes available — trigger it to deploy. + +# DEV +dry-run:dev: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: DEV + environment: + name: DEV + action: prepare + +apply:dev: + extends: .apply-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: DEV + needs: + - dry-run:dev + environment: + name: DEV + +# REC +dry-run:rec: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: REC + environment: + name: REC + action: prepare + +apply:rec: + extends: .apply-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: REC + needs: + - dry-run:rec + environment: + name: REC + +# FORM +dry-run:form: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: FORM + environment: + name: FORM + action: prepare + +apply:form: + extends: .apply-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: FORM + needs: + - dry-run:form + environment: + name: FORM + +# PERFS +dry-run:perfs: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: PERFS + environment: + name: PERFS + action: prepare + +apply:perfs: + extends: .apply-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: PERFS + needs: + - dry-run:perfs + environment: + name: PERFS + +# PREPROD +dry-run:preprod: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: PREPROD + environment: + name: PREPROD + action: prepare + +apply:preprod: + extends: .apply-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: PREPROD + needs: + - dry-run:preprod + environment: + name: PREPROD + +# PROD +dry-run:prod: + extends: .dry-run-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: PROD + environment: + name: PROD + action: prepare + +apply:prod: + extends: .apply-base + rules: + - if: $CI_COMMIT_TAG + when: manual + variables: + APIM_ENV: PROD + needs: + - dry-run:prod + environment: + name: PROD diff --git a/.cicd/globals.yml b/.cicd/globals.yml new file mode 100644 index 0000000..66ecaf6 --- /dev/null +++ b/.cicd/globals.yml @@ -0,0 +1,27 @@ +variables: + MANIFEST: apps.yaml + CONFIG: config.json + UV_CACHE_DIR: .uv-cache + +# ── Base templates ──────────────────────────────────────────────────────────── + +.uv-base: + image: ghcr.io/astral-sh/uv:python3.13-alpine + cache: + key: uv-${CI_COMMIT_REF_SLUG} + paths: + - ${UV_CACHE_DIR}/ + before_script: + - uv sync --frozen + +.dry-run-base: + extends: .uv-base + stage: dry-run + script: + - uv run python apim_apps.py --dry-run --env "$APIM_ENV" + +.apply-base: + extends: .uv-base + stage: apply + script: + - uv run python apim_apps.py --env "$APIM_ENV" diff --git a/.cicd/lint.yml b/.cicd/lint.yml new file mode 100644 index 0000000..28a7e60 --- /dev/null +++ b/.cicd/lint.yml @@ -0,0 +1,24 @@ +# Runs on: main, fix/*, feat/* +# Validates config.json and apps.yaml syntax before any deployment. + +lint:config-json: + extends: .uv-base + stage: lint + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_BRANCH =~ /^(fix|feat)\/.+/ + script: + - > + uv run python -c + "import json, sys; json.load(open('${CONFIG}')); print('${CONFIG}: OK')" + +lint:apps-yaml: + extends: .uv-base + stage: lint + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_BRANCH =~ /^(fix|feat)\/.+/ + script: + - > + uv run python -c + "import yaml; yaml.safe_load(open('${MANIFEST}')); print('${MANIFEST}: OK')" diff --git a/.cicd/release.yml b/.cicd/release.yml new file mode 100644 index 0000000..85f5999 --- /dev/null +++ b/.cicd/release.yml @@ -0,0 +1,27 @@ +# Runs on: main only — after lints pass. +# Analyses conventional commits and publishes a GitLab release + CHANGELOG. +# +# Required CI/CD variable: +# SEMANTIC_RELEASE_TOKEN — project/personal access token with api + write_repository scope. +# (CI_JOB_TOKEN does not have the permissions needed to push tags and create releases.) + +release: + stage: release + image: node:lts-alpine + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + needs: + - lint:config-json + - lint:apps-yaml + variables: + GITLAB_TOKEN: $SEMANTIC_RELEASE_TOKEN + before_script: + - npm install --no-save + semantic-release + @semantic-release/commit-analyzer + @semantic-release/release-notes-generator + @semantic-release/changelog + @semantic-release/git + @semantic-release/gitlab + script: + - npx semantic-release --config .cicd/.releaserc.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5ad503 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Claude +.claude +.pytest_cache \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ca00e6c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,17 @@ +workflow: + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # main + - if: $CI_COMMIT_BRANCH =~ /^(fix|feat)\/.+/ # fix/* and feat/* branches + - if: $CI_COMMIT_TAG # version tags + +stages: + - lint + - release + - dry-run + - apply + +include: + - local: .cicd/globals.yml + - local: .cicd/lint.yml + - local: .cicd/release.yml + - local: .cicd/deploy.yml diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e920ca9 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# apim-apps + +CLI tool for managing Axway API Manager client applications from a YAML manifest. +Ensures compliance across multiple environments by creating missing applications and API subscriptions. + +## How it works + +The tool reads a manifest (`apps.yaml`) describing the desired state, compares it against each Axway API Manager instance, and creates what is missing. + +**Organisations are managed by hand** — the tool looks them up live in the API Manager. If an application references an organisation that does not exist there, the application is skipped and the error is reported clearly. + +Credentials are never stored in config files — they are injected via environment variables at runtime. + +## Requirements + +- Python 3.13+ +- [uv](https://github.com/astral-sh/uv) +- Environment variables `APIM_USER` and `APIM_PASSWORD` set before running + +## Setup + +```bash +uv sync +``` + +Generate example files: + +```bash +uv run python apim_apps.py --init-manifest # creates apps.yaml +uv run python apim_apps.py --init-config # creates config.json +``` + +## Configuration + +### `config.json` — environment registry + +Lists the Axway API Manager instances. Credentials are **not** stored here. + +```json +{ + "environments": [ + { + "name": "DEV_LAN", + "url": "https://apimgr-dev.example.com:8075", + "verify_ssl": false + }, + { + "name": "PROD", + "url": "https://apimgr-prd.example.com:8075", + "verify_ssl": true + } + ] +} +``` + +| Field | Required | Description | +|--------------|----------|--------------------------------------------------| +| `name` | yes | Environment identifier used with `--env` | +| `url` | yes | Base URL of the API Manager instance | +| `verify_ssl` | no | Verify TLS certificate (default: `false`) | + +### `apps.yaml` — desired state manifest + +Describes the applications that must exist on each environment. +The `organizations` section is **optional** — organisations are expected to already exist in the API Manager (managed by hand). If an application's organisation is missing, the application is skipped and the error is reported. + +If `environments` is omitted on an entry, it applies to **all** environments. + +```yaml +applications: + - name: "MyApp" + organization: "MyOrg" # must already exist in the API Manager + description: "Mobile application" + email: "mobile@example.com" + enabled: true + environments: + - DEV_LAN + apis: + - name: "Products" + version: "v2" + - "Notifications" # short form — no version filter +``` + +If you also want the tool to manage organisations, add an `organizations` section: + +```yaml +organizations: + - name: "MyOrg" + description: "Partner organisation" + email: "api@example.com" + enabled: true + development: false + environments: + - DEV_LAN + +applications: + - name: "MyApp" + organization: "MyOrg" + ... +``` + +## Usage + +```bash +export APIM_USER=apiadmin +export APIM_PASSWORD=secret + +# Dry-run on a single environment +uv run python apim_apps.py --dry-run --env DEV_LAN + +# Deploy on a single environment +uv run python apim_apps.py --env DEV_LAN + +# Run against all environments in config.json +uv run python apim_apps.py + +# Extra options +uv run python apim_apps.py --manifest custom.yaml --config custom.json --verbose +``` + +### CLI reference + +| Flag | Description | +|-------------------|----------------------------------------------------------| +| `--dry-run` | Simulate actions, make no changes | +| `--env ENV` | Target a single environment by name | +| `--manifest FILE` | Path to YAML manifest (default: `apps.yaml`) | +| `--config FILE` | Path to JSON config (default: `config.json`) | +| `--verbose` | Log skipped (already-present) resources | +| `--init-manifest` | Generate a sample `apps.yaml` and exit | +| `--init-config` | Generate a sample `config.json` and exit | + +## GitLab CI/CD integration + +Store `APIM_USER` and `APIM_PASSWORD` as protected CI/CD variables in your GitLab project settings. + +```yaml +# .gitlab-ci.yml +variables: + MANIFEST: apps.yaml + CONFIG: config.json + +.apim-base: + image: ghcr.io/astral-sh/uv:python3.13-alpine + before_script: + - uv sync --frozen + +dry-run: + extends: .apim-base + script: + - uv run python apim_apps.py --dry-run --env "$APIM_ENV" + +deploy: + extends: .apim-base + when: manual + script: + - uv run python apim_apps.py --env "$APIM_ENV" +``` + +Trigger the pipeline with `APIM_ENV=DEV_LAN` to target a specific environment. + +## Running tests + +```bash +uv run pytest +``` diff --git a/apim_apps.py b/apim_apps.py new file mode 100644 index 0000000..92e46f7 --- /dev/null +++ b/apim_apps.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from apim_apps.cli import main + +if __name__ == "__main__": + main() diff --git a/apim_apps/__init__.py b/apim_apps/__init__.py new file mode 100644 index 0000000..ef52c74 --- /dev/null +++ b/apim_apps/__init__.py @@ -0,0 +1,5 @@ +from .client import AxwayClient, _AppendOnlySession +from .engine import normalize_env +from .loader import EXAMPLE_CONFIG, EXAMPLE_MANIFEST, load_config, load_manifest +from .models import Action, ApiAccess, AppSpec, EnvConfig, Manifest, NormResult, OrgSpec +from .report import print_report diff --git a/apim_apps/_colors.py b/apim_apps/_colors.py new file mode 100644 index 0000000..5e8b533 --- /dev/null +++ b/apim_apps/_colors.py @@ -0,0 +1,16 @@ +import sys + +ANSI = { + "green": "\033[32m", + "yellow": "\033[33m", + "red": "\033[31m", + "cyan": "\033[36m", + "bold": "\033[1m", + "reset": "\033[0m", +} + + +def _c(color: str, text: str) -> str: + if sys.stdout.isatty(): + return f"{ANSI[color]}{text}{ANSI['reset']}" + return text diff --git a/apim_apps/cli.py b/apim_apps/cli.py new file mode 100644 index 0000000..03b6f93 --- /dev/null +++ b/apim_apps/cli.py @@ -0,0 +1,110 @@ +import argparse +import logging +import os +import sys + +import urllib3 + +from .engine import normalize_env +from .loader import EXAMPLE_CONFIG, EXAMPLE_MANIFEST, load_config, load_manifest +from .models import NormResult +from .report import print_report +from ._colors import _c + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger(__name__) + + +def main(): + parser = argparse.ArgumentParser( + description="Axway API Manager — normalisation multi-environnements", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--manifest", default="apps.yaml", + help="Manifeste YAML des organisations/applications (défaut: apps.yaml)") + parser.add_argument("--config", default="config.json", + help="Config JSON des environnements Axway (défaut: config.json)") + parser.add_argument("--dry-run", action="store_true", + help="Simule les actions sans appliquer de changements") + parser.add_argument("--env", metavar="ENV", + help="Restreindre l'exécution à cet environnement (ex: --env DEV_LAN)") + parser.add_argument("--init-manifest", action="store_true", + help="Génère un fichier apps.yaml d'exemple et quitte") + parser.add_argument("--init-config", action="store_true", + help="Génère un fichier config.json d'exemple et quitte") + parser.add_argument("--verbose", action="store_true", + help="Affiche les actions skip (debug)") + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + if args.init_manifest: + with open(args.manifest, "w", encoding="utf-8") as f: + f.write(EXAMPLE_MANIFEST) + print(f"✅ Manifeste d'exemple créé : {args.manifest}") + return + + if args.init_config: + with open(args.config, "w", encoding="utf-8") as f: + f.write(EXAMPLE_CONFIG) + print(f"✅ Config d'exemple créée : {args.config}") + return + + apim_user = os.environ.get("APIM_USER") + apim_password = os.environ.get("APIM_PASSWORD") + if not apim_user or not apim_password: + log.error("Variables d'environnement APIM_USER et APIM_PASSWORD requises") + sys.exit(1) + + for path, label in [(args.manifest, "manifeste"), (args.config, "configuration")]: + if not os.path.exists(path): + log.error(f"Fichier {label} introuvable : {path}") + hint = "--init-manifest" if "yaml" in path else "--init-config" + log.error(f" → Utilisez `{hint}` pour générer un exemple.") + sys.exit(1) + + dry_run = args.dry_run + + if dry_run: + log.info(_c("yellow", "Mode DRY-RUN activé — aucune modification ne sera effectuée")) + else: + log.warning(_c("red", "⚠️ Mode APPLY — les changements vont être appliqués sur les instances Axway !")) + + manifest = load_manifest(args.manifest) + environments = load_config(args.config) + + for env in environments: + env.username = apim_user + env.password = apim_password + + log.info(f"Manifeste : {len(manifest.organizations)} org(s), {len(manifest.applications)} app(s)") + + if args.env: + environments = [e for e in environments if e.name == args.env] + if not environments: + log.error(f"Aucun environnement correspondant à : {args.env!r}") + sys.exit(1) + + log.info(f"Environnements ciblés : {[e.name for e in environments]}") + + results: dict[str, NormResult] = {} + for env in environments: + try: + results[env.name] = normalize_env(env, manifest, dry_run) + except Exception as e: + r = NormResult() + r.errors.append(f"Erreur fatale : {e}") + results[env.name] = r + log.exception(f"[{env.name}] Erreur fatale non gérée") + + print_report(results, dry_run) + + total_errors = sum(len(r.errors) for r in results.values()) + sys.exit(1 if total_errors else 0) diff --git a/apim_apps/client.py b/apim_apps/client.py new file mode 100644 index 0000000..596a2e2 --- /dev/null +++ b/apim_apps/client.py @@ -0,0 +1,122 @@ +from typing import Optional + +import requests + +from .models import AppSpec, EnvConfig, OrgSpec + + +class _AppendOnlySession(requests.Session): + """HTTP session that refuses DELETE requests — this tool is append-only.""" + + def request(self, method: str, url, **kwargs): + if method.upper() == "DELETE": + raise RuntimeError( + f"DELETE is not allowed: this tool never removes existing resources ({url})" + ) + return super().request(method, url, **kwargs) + + +class AxwayClient: + """Axway API Manager REST client. Endpoint: /api/portal/v1.4""" + + def __init__(self, env: EnvConfig): + self.env = env + self.base = env.url.rstrip("/") + "/api/portal/v1.4" + self.session = _AppendOnlySession() + self.session.auth = (env.username, env.password) + self.session.verify = env.verify_ssl + self.session.headers.update({"Accept": "application/json"}) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _get_all(self, path: str, params: dict = None) -> list: + url = f"{self.base}{path}" + p = dict(params or {}) + p.setdefault("count", 100) + p["startIndex"] = 0 + items = [] + while True: + r = self.session.get(url, params=p, timeout=30) + r.raise_for_status() + data = r.json() + if isinstance(data, list): + items.extend(data) + break + chunk = data.get("result", data.get("items", [])) + if not isinstance(chunk, list): + return [data] + items.extend(chunk) + total = data.get("total", len(chunk)) + p["startIndex"] += len(chunk) + if p["startIndex"] >= total or not chunk: + break + return items + + def _post(self, path: str, payload: dict) -> dict: + r = self.session.post( + f"{self.base}{path}", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + # ── Organisations ───────────────────────────────────────────────────────── + + def list_orgs(self) -> dict[str, dict]: + return {o["name"]: o for o in self._get_all("/organizations")} + + def create_org(self, spec: OrgSpec) -> dict: + return self._post("/organizations", { + "name": spec.name, + "description": spec.description, + "email": spec.email, + "phone": spec.phone, + "enabled": spec.enabled, + "development": spec.development, + }) + + # ── Applications ────────────────────────────────────────────────────────── + + def list_apps(self) -> dict[tuple, dict]: + apps = self._get_all("/applications") + orgs_by_id = {o["id"]: o["name"] for o in self._get_all("/organizations")} + return { + (orgs_by_id.get(a.get("organizationId", ""), "?"), a["name"]): a + for a in apps + } + + def create_app(self, spec: AppSpec, org_id: str) -> dict: + return self._post("/applications", { + "name": spec.name, + "organizationId": org_id, + "description": spec.description, + "email": spec.email, + "phone": spec.phone, + "enabled": spec.enabled, + "state": "approved", + }) + + # ── APIs front-end ──────────────────────────────────────────────────────── + + def list_frontend_apis(self) -> list[dict]: + return self._get_all("/proxies") + + def find_frontend_api(self, api_name: str, api_version: str = "") -> Optional[dict]: + for api in self.list_frontend_apis(): + if api.get("name", "") == api_name: + if not api_version or api.get("version", "") == api_version: + return api + return None + + # ── Souscriptions ───────────────────────────────────────────────────────── + + def list_app_api_access(self, app_id: str) -> list[dict]: + try: + return self._get_all(f"/applications/{app_id}/apis") + except requests.HTTPError: + return [] + + def grant_api_access(self, app_id: str, api_id: str) -> dict: + return self._post(f"/applications/{app_id}/apis/{api_id}", {}) diff --git a/apim_apps/engine.py b/apim_apps/engine.py new file mode 100644 index 0000000..44da220 --- /dev/null +++ b/apim_apps/engine.py @@ -0,0 +1,173 @@ +import logging + +from ._colors import _c +from .client import AxwayClient +from .models import Action, EnvConfig, Manifest, NormResult + +log = logging.getLogger(__name__) + + +def _applies_to(target_envs: list[str], env_name: str) -> bool: + return not target_envs or env_name in target_envs + + +def normalize_env(env: EnvConfig, manifest: Manifest, dry_run: bool) -> NormResult: + result = NormResult() + tag = f"[{env.name}]" + + log.info(f"{tag} Connexion à {env.url} …") + client = AxwayClient(env) + + # ── 1. Organisations ────────────────────────────────────────────────────── + try: + existing_orgs = client.list_orgs() + except Exception as e: + result.errors.append(f"{tag} Impossible de lister les organisations : {e}") + return result + + for org_spec in manifest.organizations: + if not _applies_to(org_spec.environments, env.name): + continue + + action = Action( + kind="create_org", + env_name=env.name, + description=f"Créer organisation « {org_spec.name} »", + detail={"org": org_spec.name}, + ) + + if org_spec.name in existing_orgs: + log.debug(f"{tag} Org « {org_spec.name} » déjà présente — skip") + result.actions_skipped.append(action) + continue + + log.info(_c("yellow", f"{tag} {'[DRY-RUN] ' if dry_run else ''}Création org « {org_spec.name} »")) + if not dry_run: + try: + created = client.create_org(org_spec) + existing_orgs[org_spec.name] = created + log.info(_c("green", f"{tag} ✅ Org « {org_spec.name} » créée (id={created.get('id')})")) + except Exception as e: + msg = f"{tag} ❌ Échec création org « {org_spec.name} » : {e}" + log.error(msg) + result.errors.append(msg) + continue + else: + # Placeholder so apps referencing this org are processed in dry-run + existing_orgs[org_spec.name] = {"id": "", "name": org_spec.name} + result.actions_done.append(action) + + # ── 2. Applications ─────────────────────────────────────────────────────── + try: + existing_apps = client.list_apps() + except Exception as e: + result.errors.append(f"{tag} Impossible de lister les applications : {e}") + return result + + for app_spec in manifest.applications: + if not _applies_to(app_spec.environments, env.name): + continue + + key = (app_spec.org_name, app_spec.name) + + action_app = Action( + kind="create_app", + env_name=env.name, + description=f"Créer application « {app_spec.name} » dans org « {app_spec.org_name} »", + detail={"org": app_spec.org_name, "app": app_spec.name}, + ) + + raw_org = existing_orgs.get(app_spec.org_name) + if raw_org is None: + msg = ( + f"{tag} ❌ Organisation « {app_spec.org_name} » introuvable dans l'API Manager " + f"— application « {app_spec.name} » ignorée. " + f"Créez l'organisation manuellement dans l'API Manager." + ) + log.error(msg) + result.errors.append(msg) + continue + + org_id = raw_org["id"] + + if key not in existing_apps: + log.info(_c("yellow", f"{tag} {'[DRY-RUN] ' if dry_run else ''}Création app « {app_spec.name} » (org={app_spec.org_name})")) + if not dry_run: + try: + created_app = client.create_app(app_spec, org_id) + existing_apps[key] = created_app + log.info(_c("green", f"{tag} ✅ App « {app_spec.name} » créée (id={created_app.get('id')})")) + except Exception as e: + msg = f"{tag} ❌ Échec création app « {app_spec.name} » : {e}" + log.error(msg) + result.errors.append(msg) + continue + result.actions_done.append(action_app) + else: + result.actions_skipped.append(action_app) + + # ── 3. Souscriptions aux APIs ───────────────────────────────────────── + if not app_spec.apis: + continue + + raw_app = existing_apps.get(key) + if raw_app is None: + continue # app non créée (dry-run ou erreur) + + app_id = raw_app.get("id", "") + + try: + granted = client.list_app_api_access(app_id) + granted_ids = {a.get("apiId", a.get("id", "")) for a in granted} + granted_names = {a.get("apiName", a.get("name", "")) for a in granted} + except Exception as e: + log.warning(f"{tag} Impossible de lire les accès API de « {app_spec.name} » : {e}") + granted_ids = set() + granted_names = set() + + for api_access in app_spec.apis: + action_api = Action( + kind="grant_api", + env_name=env.name, + description=( + f"Abonner « {app_spec.name} » à l'API « {api_access.api_name} »" + + (f" v{api_access.api_version}" if api_access.api_version else "") + ), + detail={"app": app_spec.name, "api": api_access.api_name, "version": api_access.api_version}, + ) + + if api_access.api_name in granted_names: + log.debug(f"{tag} API « {api_access.api_name} » déjà accordée à « {app_spec.name} » — skip") + result.actions_skipped.append(action_api) + continue + + if dry_run: + log.info(_c("yellow", f"{tag} [DRY-RUN] Abonnement « {app_spec.name} » → API « {api_access.api_name} »")) + result.actions_done.append(action_api) + continue + + fe_api = client.find_frontend_api(api_access.api_name, api_access.api_version) + if fe_api is None: + msg = ( + f"{tag} ⚠️ API front-end « {api_access.api_name} » introuvable " + f"— souscription de « {app_spec.name} » ignorée" + ) + log.warning(msg) + result.errors.append(msg) + continue + + api_id = fe_api["id"] + if api_id in granted_ids: + result.actions_skipped.append(action_api) + continue + + try: + client.grant_api_access(app_id, api_id) + log.info(_c("green", f"{tag} ✅ Accès API « {api_access.api_name} » accordé à « {app_spec.name} »")) + result.actions_done.append(action_api) + except Exception as e: + msg = f"{tag} ❌ Échec souscription « {app_spec.name} » → « {api_access.api_name} » : {e}" + log.error(msg) + result.errors.append(msg) + + return result diff --git a/apim_apps/loader.py b/apim_apps/loader.py new file mode 100644 index 0000000..cd8f87c --- /dev/null +++ b/apim_apps/loader.py @@ -0,0 +1,125 @@ +import json +import textwrap + +import yaml + +from .models import ApiAccess, AppSpec, EnvConfig, Manifest, OrgSpec + +EXAMPLE_MANIFEST = textwrap.dedent("""\ +# ============================================================================= +# Manifeste de normalisation Axway API Manager +# ============================================================================= +# Décrit les applications clientes à garantir sur les environnements. +# +# Les organisations sont gérées manuellement dans l'API Manager. +# Ce manifeste ne contient que la section "applications". +# +# Chaque objet peut cibler des environnements spécifiques via "environments:". +# Si "environments" est absent ou vide → s'applique à TOUS les environnements. +# ============================================================================= + +applications: + + # Application présente sur tous les environnements + - name: "MonAppMobile" + organization: "Partenaires-Externes" + description: "Application mobile grand public" + email: "mobile-app@example.com" + enabled: true + apis: + - name: "Catalogue-Produits" + version: "v2" + - name: "Commandes" + version: "v1" + - "Notifications" # syntaxe courte (sans version) + + # Application restreinte à PROD et PREPROD + - name: "BackOffice-CRM" + organization: "Equipe-Integration" + description: "Connecteur CRM back-office" + email: "crm-team@example.com" + enabled: true + environments: + - PREPROD + - PROD + apis: + - name: "Clients" + version: "v3" + - name: "Contrats" + version: "v1" + + # Application de test uniquement sur DEV et RECETTE + - name: "Robot-Tests-API" + organization: "Equipe-QA" + description: "Client Robot Framework pour tests d'intégration" + enabled: true + environments: + - DEV + - RECETTE + apis: + - "Catalogue-Produits" + - "Commandes" + - "Clients" +""") + +EXAMPLE_CONFIG = json.dumps({ + "environments": [ + {"name": "DEV", "url": "https://apimgr-dev.example.com:8075", "verify_ssl": False}, + {"name": "RECETTE", "url": "https://apimgr-rec.example.com:8075", "verify_ssl": False}, + {"name": "PREPROD", "url": "https://apimgr-ppd.example.com:8075", "verify_ssl": False}, + {"name": "PROD", "url": "https://apimgr-prd.example.com:8075", "verify_ssl": True}, + ] +}, indent=2, ensure_ascii=False) + + +def load_manifest(path: str) -> Manifest: + with open(path, encoding="utf-8") as f: + raw = yaml.safe_load(f) + + manifest = Manifest() + + for o in raw.get("organizations", []): + manifest.organizations.append(OrgSpec( + name=o["name"], + description=o.get("description", ""), + email=o.get("email", ""), + phone=o.get("phone", ""), + enabled=o.get("enabled", True), + development=o.get("development", False), + environments=o.get("environments", []), + )) + + for a in raw.get("applications", []): + apis = [] + for api in a.get("apis", []): + if isinstance(api, str): + apis.append(ApiAccess(api_name=api)) + else: + apis.append(ApiAccess(api_name=api["name"], api_version=api.get("version", ""))) + manifest.applications.append(AppSpec( + name=a["name"], + org_name=a["organization"], + description=a.get("description", ""), + email=a.get("email", ""), + phone=a.get("phone", ""), + enabled=a.get("enabled", True), + apis=apis, + environments=a.get("environments", []), + )) + + return manifest + + +def load_config(path: str) -> list[EnvConfig]: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + return [ + EnvConfig( + name=e["name"], + url=e["url"], + username="", + password="", + verify_ssl=e.get("verify_ssl", False), + ) + for e in raw["environments"] + ] diff --git a/apim_apps/models.py b/apim_apps/models.py new file mode 100644 index 0000000..c3238a7 --- /dev/null +++ b/apim_apps/models.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass, field + + +@dataclass +class ApiAccess: + api_name: str + api_version: str = "" + + +@dataclass +class AppSpec: + name: str + org_name: str + description: str = "" + email: str = "" + phone: str = "" + enabled: bool = True + apis: list["ApiAccess"] = field(default_factory=list) + environments: list[str] = field(default_factory=list) + + +@dataclass +class OrgSpec: + name: str + description: str = "" + email: str = "" + phone: str = "" + enabled: bool = True + development: bool = False + environments: list[str] = field(default_factory=list) + + +@dataclass +class Manifest: + organizations: list[OrgSpec] = field(default_factory=list) + applications: list[AppSpec] = field(default_factory=list) + + +@dataclass +class EnvConfig: + name: str + url: str + username: str + password: str + verify_ssl: bool = False + + +@dataclass +class Action: + kind: str # "create_org" | "create_app" | "grant_api" + env_name: str + description: str + detail: dict = field(default_factory=dict) + + +@dataclass +class NormResult: + actions_done: list[Action] = field(default_factory=list) + actions_skipped: list[Action] = field(default_factory=list) + errors: list[str] = field(default_factory=list) diff --git a/apim_apps/report.py b/apim_apps/report.py new file mode 100644 index 0000000..a5597c6 --- /dev/null +++ b/apim_apps/report.py @@ -0,0 +1,52 @@ +from ._colors import _c +from .models import NormResult + + +def print_report(results: dict[str, NormResult], dry_run: bool): + mode = _c("yellow", "[DRY-RUN] ") if dry_run else "" + print() + print(_c("bold", f"{'─'*65}")) + print(_c("bold", f" {mode}Rapport de normalisation")) + print(_c("bold", f"{'─'*65}")) + + total_done = total_skip = total_err = 0 + + for env_name, result in results.items(): + done = len(result.actions_done) + skip = len(result.actions_skipped) + err = len(result.errors) + total_done += done + total_skip += skip + total_err += err + + status = _c("green", "✅") if err == 0 else _c("red", "❌") + print(f"\n {status} {_c('bold', env_name)}") + print(f" Actions {'simulées' if dry_run else 'effectuées'} : {_c('green', str(done))}") + print(f" Déjà présents (skip) : {_c('cyan', str(skip))}") + print(f" Erreurs / avertissements : {_c('red', str(err)) if err else _c('green', '0')}") + + if result.actions_done: + print(f" {'─'*50}") + for a in result.actions_done: + icon = {"create_org": "🏢", "create_app": "📱", "grant_api": "🔗"}.get(a.kind, "•") + prefix = " ~ " if dry_run else " + " + print(f" {prefix}{icon} {a.description}") + + if result.errors: + print(f" {'─'*50}") + for e in result.errors: + print(f" ⚠️ {_c('red', e)}") + + print() + print(_c("bold", f"{'─'*65}")) + print( + f" Total — {'simulé' if dry_run else 'effectué'}: {_c('green', str(total_done))} | " + f"skip: {_c('cyan', str(total_skip))} | " + f"erreurs: {_c('red', str(total_err)) if total_err else _c('green', '0')}" + ) + print(_c("bold", f"{'─'*65}")) + if dry_run: + print(_c("yellow", "\n ℹ️ Mode DRY-RUN : aucun changement appliqué.")) + print(_c("yellow", " Relancez sans --dry-run pour appliquer les modifications.\n")) + else: + print() diff --git a/apps.yaml b/apps.yaml new file mode 100644 index 0000000..4eca750 --- /dev/null +++ b/apps.yaml @@ -0,0 +1,54 @@ +# ============================================================================= +# Manifeste de normalisation Axway API Manager +# ============================================================================= +# Décrit les applications clientes à garantir sur les environnements. +# +# Les organisations sont gérées manuellement dans l'API Manager. +# Ce manifeste ne contient que la section "applications". +# +# Chaque objet peut cibler des environnements spécifiques via "environments:". +# Si "environments" est absent ou vide → s'applique à TOUS les environnements. +# ============================================================================= + +applications: + + # Application présente sur tous les environnements + - name: "MonAppMobile" + organization: "Partenaires-Externes" + description: "Application mobile grand public" + email: "mobile-app@example.com" + enabled: true + apis: + - name: "Catalogue-Produits" + version: "v2" + - name: "Commandes" + version: "v1" + - "Notifications" # syntaxe courte (sans version) + + # Application restreinte à PROD et PREPROD + - name: "BackOffice-CRM" + organization: "Equipe-Integration" + description: "Connecteur CRM back-office" + email: "crm-team@example.com" + enabled: true + environments: + - PREPROD + - PROD + apis: + - name: "Clients" + version: "v3" + - name: "Contrats" + version: "v1" + + # Application de test uniquement sur DEV et RECETTE + - name: "Robot-Tests-API" + organization: "Equipe-QA" + description: "Client Robot Framework pour tests d'intégration" + enabled: true + environments: + - DEV + - RECETTE + apis: + - "Catalogue-Produits" + - "Commandes" + - "Clients" diff --git a/config.json b/config.json new file mode 100644 index 0000000..4bba7e5 --- /dev/null +++ b/config.json @@ -0,0 +1,24 @@ +{ + "environments": [ + { + "name": "DEV", + "url": "https://apimgr-dev.example.com:8075", + "verify_ssl": false + }, + { + "name": "RECETTE", + "url": "https://apimgr-rec.example.com:8075", + "verify_ssl": false + }, + { + "name": "PREPROD", + "url": "https://apimgr-ppd.example.com:8075", + "verify_ssl": false + }, + { + "name": "PROD", + "url": "https://apimgr-prd.example.com:8075", + "verify_ssl": true + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8d8280 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "apim-apps" +version = "0.1.0" +description = "Axway API Manager compliance tool — manage organisations and applications from a YAML manifest" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "requests>=2.32", + "pyyaml>=6.0", + "urllib3>=2.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-mock>=3.14", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_append_only.py b/tests/test_append_only.py new file mode 100644 index 0000000..fcf1dd5 --- /dev/null +++ b/tests/test_append_only.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import patch, MagicMock +from apim_apps import AxwayClient, _AppendOnlySession, EnvConfig + + +@pytest.fixture +def client(): + env = EnvConfig(name="TEST", url="https://test.example.com:8075", username="u", password="p") + return AxwayClient(env) + + +def test_session_is_append_only(client): + assert isinstance(client.session, _AppendOnlySession) + + +def test_delete_raises_runtime_error(client): + with pytest.raises(RuntimeError, match="DELETE is not allowed"): + client.session.delete("https://test.example.com:8075/api/portal/v1.4/applications/123") + + +def test_get_is_allowed(client): + with patch("requests.Session.request") as mock_req: + mock_req.return_value = MagicMock(status_code=200) + client.session.get("https://test.example.com") + mock_req.assert_called_once() + assert mock_req.call_args[0][0].upper() == "GET" + + +def test_post_is_allowed(client): + with patch("requests.Session.request") as mock_req: + mock_req.return_value = MagicMock(status_code=201) + client.session.post("https://test.example.com", json={}) + mock_req.assert_called_once() + assert mock_req.call_args[0][0].upper() == "POST" + + +def test_no_delete_method_on_client(): + """AxwayClient must not expose any delete_* method.""" + delete_methods = [m for m in dir(AxwayClient) if m.startswith("delete")] + assert delete_methods == [], f"Unexpected delete methods found: {delete_methods}" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..cd84064 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,116 @@ +import json +import os +import textwrap +import pytest +from unittest.mock import patch +from apim_apps.cli import main + + +SAMPLE_CONFIG = json.dumps({ + "environments": [{"name": "DEV_LAN", "url": "https://dev.example.com:8075"}] +}) + +SAMPLE_MANIFEST = textwrap.dedent("""\ + organizations: [] + applications: [] +""") + + +@pytest.fixture +def project_files(tmp_path): + (tmp_path / "config.json").write_text(SAMPLE_CONFIG) + (tmp_path / "apps.yaml").write_text(SAMPLE_MANIFEST) + return tmp_path + + +# --------------------------------------------------------------------------- +# Credential validation +# --------------------------------------------------------------------------- + +def test_exits_when_apim_user_missing(project_files): + argv = ["apim_apps.py", "--config", str(project_files / "config.json"), + "--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"] + env = {"APIM_PASSWORD": "secret"} + with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_exits_when_apim_password_missing(project_files): + argv = ["apim_apps.py", "--config", str(project_files / "config.json"), + "--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"] + env = {"APIM_USER": "admin"} + with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_exits_when_both_credentials_missing(project_files): + argv = ["apim_apps.py", "--config", str(project_files / "config.json"), + "--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"] + with patch("sys.argv", argv), patch.dict(os.environ, {}, clear=True): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +# --------------------------------------------------------------------------- +# --env filtering +# --------------------------------------------------------------------------- + +def test_exits_when_env_not_found(project_files): + argv = ["apim_apps.py", "--config", str(project_files / "config.json"), + "--manifest", str(project_files / "apps.yaml"), "--env", "UNKNOWN"] + env = {"APIM_USER": "admin", "APIM_PASSWORD": "secret"} + with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + +def test_dry_run_succeeds_with_valid_env(project_files): + argv = ["apim_apps.py", "--config", str(project_files / "config.json"), + "--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"] + env = {"APIM_USER": "admin", "APIM_PASSWORD": "secret"} + with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True): + with patch("apim_apps.engine.AxwayClient") as MockClient: + instance = MockClient.return_value + instance.list_orgs.return_value = {} + instance.list_apps.return_value = {} + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 0 + + +# --------------------------------------------------------------------------- +# --init-* helpers +# --------------------------------------------------------------------------- + +def test_init_manifest_creates_file(tmp_path): + out = tmp_path / "apps.yaml" + with patch("sys.argv", ["apim_apps.py", "--init-manifest", "--manifest", str(out)]): + main() + assert out.exists() + assert out.stat().st_size > 0 + + +def test_init_config_creates_file(tmp_path): + out = tmp_path / "config.json" + with patch("sys.argv", ["apim_apps.py", "--init-config", "--config", str(out)]): + main() + assert out.exists() + data = json.loads(out.read_text()) + assert "environments" in data + + +def test_init_config_no_credentials_in_template(tmp_path): + """Generated config.json must not contain username or password.""" + out = tmp_path / "config.json" + with patch("sys.argv", ["apim_apps.py", "--init-config", "--config", str(out)]): + main() + data = json.loads(out.read_text()) + for entry in data["environments"]: + assert "username" not in entry + assert "password" not in entry diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1a615b0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,43 @@ +import json +import pytest +from apim_apps import load_config + +SAMPLE = { + "environments": [ + {"name": "DEV_LAN", "url": "https://dev.example.com:8075", "verify_ssl": False}, + {"name": "PROD", "url": "https://prod.example.com:8075", "verify_ssl": True}, + ] +} + + +def test_load_config_names_and_urls(tmp_path): + f = tmp_path / "config.json" + f.write_text(json.dumps(SAMPLE)) + envs = load_config(str(f)) + + assert len(envs) == 2 + assert envs[0].name == "DEV_LAN" + assert envs[0].url == "https://dev.example.com:8075" + assert envs[0].verify_ssl is False + assert envs[1].name == "PROD" + assert envs[1].verify_ssl is True + + +def test_credentials_not_loaded_from_config(tmp_path): + """Credentials come from env vars, not from config.json.""" + f = tmp_path / "config.json" + f.write_text(json.dumps(SAMPLE)) + envs = load_config(str(f)) + + for env in envs: + assert env.username == "" + assert env.password == "" + + +def test_verify_ssl_defaults_to_false(tmp_path): + config = {"environments": [{"name": "DEV", "url": "https://dev.example.com:8075"}]} + f = tmp_path / "config.json" + f.write_text(json.dumps(config)) + envs = load_config(str(f)) + + assert envs[0].verify_ssl is False diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..19dabac --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,96 @@ +import textwrap +import pytest +from apim_apps import load_manifest, ApiAccess + +MINIMAL = textwrap.dedent("""\ + organizations: [] + applications: [] +""") + +FULL = textwrap.dedent("""\ + organizations: + - name: "MyOrg" + description: "Test org" + email: "org@example.com" + enabled: true + development: false + environments: + - DEV_LAN + + applications: + - name: "MyApp" + organization: "MyOrg" + description: "Test app" + email: "app@example.com" + enabled: true + environments: + - DEV_LAN + apis: + - name: "MyAPI" + version: "v1" + - "OtherAPI" +""") + +NO_ENVS = textwrap.dedent("""\ + organizations: + - name: "GlobalOrg" + applications: + - name: "GlobalApp" + organization: "GlobalOrg" +""") + + +def test_minimal(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(MINIMAL) + m = load_manifest(str(f)) + assert m.organizations == [] + assert m.applications == [] + + +def test_full_org_fields(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(FULL) + m = load_manifest(str(f)) + + assert len(m.organizations) == 1 + org = m.organizations[0] + assert org.name == "MyOrg" + assert org.description == "Test org" + assert org.email == "org@example.com" + assert org.enabled is True + assert org.development is False + assert org.environments == ["DEV_LAN"] + + +def test_full_app_fields(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(FULL) + m = load_manifest(str(f)) + + assert len(m.applications) == 1 + app = m.applications[0] + assert app.name == "MyApp" + assert app.org_name == "MyOrg" + assert app.environments == ["DEV_LAN"] + + +def test_api_access_named(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(FULL) + m = load_manifest(str(f)) + apis = m.applications[0].apis + + assert len(apis) == 2 + assert apis[0] == ApiAccess(api_name="MyAPI", api_version="v1") + assert apis[1] == ApiAccess(api_name="OtherAPI", api_version="") + + +def test_defaults_when_envs_omitted(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(NO_ENVS) + m = load_manifest(str(f)) + + assert m.organizations[0].environments == [] + assert m.applications[0].environments == [] + assert m.applications[0].apis == [] diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..7d564c6 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,198 @@ +import pytest +from unittest.mock import MagicMock, patch +from apim_apps import normalize_env, EnvConfig, Manifest, OrgSpec, AppSpec, ApiAccess + + +@pytest.fixture +def env(): + return EnvConfig(name="TEST", url="https://test.example.com:8075", username="u", password="p") + + +@pytest.fixture +def mock_client(): + with patch("apim_apps.engine.AxwayClient") as MockClient: + instance = MockClient.return_value + instance.list_orgs.return_value = {} + instance.list_apps.return_value = {} + instance.list_app_api_access.return_value = [] + yield instance + + +def _manifest(orgs=None, apps=None): + return Manifest(organizations=orgs or [], applications=apps or []) + + +# --------------------------------------------------------------------------- +# Dry-run +# --------------------------------------------------------------------------- + +def test_dry_run_makes_no_api_calls(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="MyAPI")])], + ) + mock_client.list_orgs.return_value = {} + mock_client.list_apps.return_value = {} + + result = normalize_env(env, manifest, dry_run=True) + + mock_client.create_org.assert_not_called() + mock_client.create_app.assert_not_called() + mock_client.grant_api_access.assert_not_called() + assert len(result.errors) == 0 + + +def test_dry_run_reports_planned_actions(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg")], + ) + result = normalize_env(env, manifest, dry_run=True) + + kinds = {a.kind for a in result.actions_done} + assert "create_org" in kinds + assert "create_app" in kinds + + +# --------------------------------------------------------------------------- +# Apply mode +# --------------------------------------------------------------------------- + +def test_apply_creates_org_and_app(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg")], + ) + mock_client.create_org.return_value = {"id": "org-1", "name": "MyOrg"} + mock_client.create_app.return_value = {"id": "app-1", "name": "MyApp"} + + result = normalize_env(env, manifest, dry_run=False) + + mock_client.create_org.assert_called_once() + mock_client.create_app.assert_called_once() + assert len(result.errors) == 0 + + +def test_apply_grants_api_subscription(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="MyAPI")])], + ) + mock_client.create_org.return_value = {"id": "org-1", "name": "MyOrg"} + mock_client.create_app.return_value = {"id": "app-1", "name": "MyApp"} + mock_client.find_frontend_api.return_value = {"id": "api-1", "name": "MyAPI"} + + normalize_env(env, manifest, dry_run=False) + + mock_client.grant_api_access.assert_called_once_with("app-1", "api-1") + + +def test_apply_skips_missing_frontend_api(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="Ghost")])], + ) + mock_client.create_org.return_value = {"id": "org-1", "name": "MyOrg"} + mock_client.create_app.return_value = {"id": "app-1", "name": "MyApp"} + mock_client.find_frontend_api.return_value = None + + result = normalize_env(env, manifest, dry_run=False) + + mock_client.grant_api_access.assert_not_called() + assert any("Ghost" in e for e in result.errors) + + +# --------------------------------------------------------------------------- +# Skip when already present +# --------------------------------------------------------------------------- + +def test_skips_existing_org(env, mock_client): + manifest = _manifest(orgs=[OrgSpec(name="MyOrg")]) + mock_client.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}} + + result = normalize_env(env, manifest, dry_run=False) + + mock_client.create_org.assert_not_called() + assert any(a.kind == "create_org" for a in result.actions_skipped) + + +def test_skips_existing_app(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg")], + ) + mock_client.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}} + mock_client.list_apps.return_value = {("MyOrg", "MyApp"): {"id": "app-1", "name": "MyApp"}} + + result = normalize_env(env, manifest, dry_run=False) + + mock_client.create_app.assert_not_called() + assert any(a.kind == "create_app" for a in result.actions_skipped) + + +def test_skips_already_granted_api(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="MyOrg")], + apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="MyAPI")])], + ) + mock_client.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}} + mock_client.list_apps.return_value = {("MyOrg", "MyApp"): {"id": "app-1", "name": "MyApp"}} + mock_client.list_app_api_access.return_value = [{"apiName": "MyAPI", "id": "api-1"}] + + result = normalize_env(env, manifest, dry_run=False) + + mock_client.grant_api_access.assert_not_called() + assert any(a.kind == "grant_api" for a in result.actions_skipped) + + +# --------------------------------------------------------------------------- +# Environment filtering +# --------------------------------------------------------------------------- + +def test_env_filter_excludes_other_env(env, mock_client): + manifest = _manifest( + orgs=[ + OrgSpec(name="GlobalOrg"), + OrgSpec(name="ProdOnly", environments=["PROD"]), + ] + ) + result = normalize_env(env, manifest, dry_run=True) + + org_names = [a.detail["org"] for a in result.actions_done + result.actions_skipped] + assert "GlobalOrg" in org_names + assert "ProdOnly" not in org_names + + +def test_env_filter_matches_current_env(env, mock_client): + manifest = _manifest( + orgs=[OrgSpec(name="TestOnly", environments=["TEST"])] + ) + result = normalize_env(env, manifest, dry_run=True) + + org_names = [a.detail["org"] for a in result.actions_done + result.actions_skipped] + assert "TestOnly" in org_names + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + +def test_error_when_org_missing_for_app(env, mock_client): + manifest = _manifest( + apps=[AppSpec(name="Orphan", org_name="MissingOrg")] + ) + mock_client.list_orgs.return_value = {} + + result = normalize_env(env, manifest, dry_run=False) + + assert any("MissingOrg" in e for e in result.errors) + + +def test_list_orgs_failure_returns_error(env, mock_client): + import requests + mock_client.list_orgs.side_effect = requests.ConnectionError("timeout") + manifest = _manifest(orgs=[OrgSpec(name="MyOrg")]) + + result = normalize_env(env, manifest, dry_run=False) + + assert len(result.errors) == 1 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b2826a5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,240 @@ +version = 1 +revision = 1 +requires-python = ">=3.13" + +[[package]] +name = "apim-apps" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.32" }, + { name = "urllib3", specifier = ">=2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-mock", specifier = ">=3.14" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +]