feat(apim-apps): first commit

This commit is contained in:
2026-06-29 14:22:37 +02:00
commit 7738a51d16
28 changed files with 1973 additions and 0 deletions
+19
View File
@@ -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"
}]
]
}
+179
View File
@@ -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
+27
View File
@@ -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"
+24
View File
@@ -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')"
+27
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Claude
.claude
.pytest_cache
+17
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.13
+166
View File
@@ -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
```
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python3
from apim_apps.cli import main
if __name__ == "__main__":
main()
+5
View File
@@ -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
+16
View File
@@ -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
+110
View File
@@ -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)
+122
View File
@@ -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}", {})
+173
View File
@@ -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
+125
View File
@@ -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"]
]
+60
View File
@@ -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)
+52
View File
@@ -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()
+54
View File
@@ -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"
+24
View File
@@ -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
}
]
}
+20
View File
@@ -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"]
View File
+40
View File
@@ -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}"
+116
View File
@@ -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
+43
View File
@@ -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
+96
View File
@@ -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 == []
+198
View File
@@ -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
Generated
+240
View File
@@ -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 },
]