From ef1bef8888c42666bdf195442784bad46eafc249 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 10 Jun 2026 06:02:57 -0400 Subject: [PATCH 01/12] ci: update publish workflow and package versions --- .github/workflows/publish.yml | 49 +++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d79cda0..7e7074f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,32 +3,55 @@ name: Publish to PyPI on: release: types: [published] + workflow_dispatch: + inputs: + pypi_target: + description: 'PyPI target (pypi or testpypi)' + default: 'pypi' + type: choice + options: + - pypi + - testpypi permissions: contents: read + id-token: write jobs: - deploy: + publish: runs-on: ubuntu-latest + environment: pypi + steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: persist-credentials: false - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: "3.11" - - name: Install build tools + python-version: "3.12" + + - name: Install dependencies run: | - pip install --upgrade pip - pip install build twine + python -m pip install --upgrade pip + pip install build twine ruff + - name: Lint with ruff - run: pip install ruff && ruff check src/ tests/ + run: ruff check src/ --target-version py310 - name: Build package run: python -m build + + - name: Check package + run: twine check dist/* + + - name: Publish to TestPyPI + if: ${{ inputs.pypi_target == 'testpypi' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: twine upload dist/* + if: ${{ inputs.pypi_target == 'pypi' || github.event_name == 'release' }} + uses: pypa/gh-action-pypi-publish@release/v1 From 96ed4a8fb3e13b689d896c79a75f8e86513985fd Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Tue, 23 Jun 2026 02:42:01 -0400 Subject: [PATCH 02/12] docs: add AGENTS.md for agent discoverability --- AGENTS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d8c5b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# apighost + +## Purpose +CLI tool that reads an OpenAPI spec and spawns a realistic mock API server with VCR cassette recording and replay — OpenAPI spec → mock server with VCR recording. + +## Build & Test Commands +- Install: `pip install -e .` or `pip install git+https://github.com/Coding-Dev-Tools/apighost.git` +- Test: `pytest tests/` (or `python -m pytest tests/ -v --tb=short`) +- Lint: `ruff check src/ tests/` +- Build: `pip install build twine && python -m build && twine check dist/*` +- CLI check: `apighost --help` + +## Architecture +Key directories: +- `src/apighost/` — Main package (CLI, OpenAPI parser, mock server, VCR recorder, scenarios) +- `tests/` — Test suite +- `.github/workflows/` — CI/CD (auto-code-review.yml, ci.yml, pages.yml, publish.yml) +- `dist/` — Built distributions + +## Conventions +- Language: Python 3.10+ +- Test framework: pytest +- CI: GitHub Actions (lint job + test job with matrix: Python 3.10, 3.11, 3.12, 3.13) +- Linting: ruff (line-length 120, target py310) +- Formatting: ruff +- Package layout: src/ layout with setuptools +- Dependencies: click, pyyaml, faker, flask, rich, requests, jsonschema, werkzeug +- CLI entry point: apighost.cli:cli +- Master branch: master \ No newline at end of file From ba97b270640530286b799e530b176981529cadff Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 28 Jun 2026 23:55:28 -0400 Subject: [PATCH 03/12] improve: update GitHub Actions checkout and setup-python actions to latest versions --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/pages.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 704a481..c200ef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,10 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + - uses: actions/setup-python@v5 with: python-version: '3.13' - run: pip install ruff @@ -30,12 +30,12 @@ jobs: python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e64318a..31993c2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,7 +18,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Setup Pages From 1adcc924b4347e2e17b21332839c6c970798c8cb Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 00:05:08 -0400 Subject: [PATCH 04/12] chore: trigger CI rerun --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a939dd4..e79342a 100644 --- a/README.md +++ b/README.md @@ -179,3 +179,4 @@ MIT — see [LICENSE](LICENSE) Part of the [Coding-Dev-Tools](https://github.com/Coding-Dev-Tools) ecosystem — a suite of developer CLI tools. Also check out [API Contract Guardian](https://github.com/Coding-Dev-Tools/api-contract-guardian) (breaking change detection), [DeployDiff](https://github.com/Coding-Dev-Tools/deploydiff) (infrastructure diffs), [json2sql](https://github.com/Coding-Dev-Tools/json2sql) (JSON → SQL), [ConfigDrift](https://github.com/Coding-Dev-Tools/configdrift) (config drift detection), [DeadCode](https://github.com/Coding-Dev-Tools/deadcode) (dead code cleanup), [Envault](https://github.com/Coding-Dev-Tools/envault) (env sync), [SchemaForge](https://github.com/Coding-Dev-Tools/schemaforge) (ORM converter), and [click-to-mcp](https://github.com/Coding-Dev-Tools/click-to-mcp) (CLI → MCP server). +# CI Trigger From c1971bdfb6724aca21879bac9eafed5ab0c574ce Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 00:41:57 -0400 Subject: [PATCH 05/12] fix: ruff lint fixes by reviewer-B - Fix import ordering in cli.py (I001) - Replace random.choice with _faker.random_element in faker_utils.py to avoid unused import (F401) - Remove unused 'random' import from server.py (F401) - Remove duplicate imports in faker_utils.py and server.py (F811) - All 170 tests pass, ruff clean --- src/apighost/faker_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apighost/faker_utils.py b/src/apighost/faker_utils.py index 190c93d..8745d56 100644 --- a/src/apighost/faker_utils.py +++ b/src/apighost/faker_utils.py @@ -51,9 +51,9 @@ "body": lambda: _faker.paragraph(nb_sentences=4), "message": lambda: _faker.sentence(), "error": lambda: _faker.sentence(), - "status": lambda: random.choice(["active", "inactive", "pending", "archived"]), - "type": lambda: random.choice(["user", "admin", "moderator", "guest"]), - "role": lambda: random.choice(["admin", "editor", "viewer", "contributor"]), + "status": lambda: _faker.random_element(["active", "inactive", "pending", "archived"]), + "type": lambda: _faker.random_element(["user", "admin", "moderator", "guest"]), + "role": lambda: _faker.random_element(["admin", "editor", "viewer", "contributor"]), "slug": lambda: _faker.slug(), "avatar": lambda: f"https://api.dicebear.com/7.x/avataaars/svg?seed={_faker.user_name()}", "image": lambda: f"https://picsum.photos/seed/{_faker.random_int()}/400/300", From 70917c76047d6af9f0a5e04f0f15efa70c9b737f Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 00:58:29 -0400 Subject: [PATCH 06/12] ci: trigger CI run for reviewer-B fixes From f07f9b83c38f6d3a5f2106943470c5d3ec3980ee Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 01:20:01 -0400 Subject: [PATCH 07/12] fix: ruff lint and format fixes by reviewer-A --- src/apighost/__main__.py | 1 + src/apighost/cli.py | 26 ++++----- src/apighost/faker_utils.py | 6 +-- src/apighost/parser.py | 25 ++++----- src/apighost/scenario.py | 20 +++---- src/apighost/schema.py | 7 +++ src/apighost/server.py | 30 ++++++----- src/apighost/vcr.py | 59 +++++++++++++-------- tests/conftest.py | 4 ++ tests/test_cli.py | 102 ++++++++++++++++++++++++++---------- tests/test_cli_record.py | 10 ++++ tests/test_faker_utils.py | 65 ++++++++++++----------- tests/test_parser.py | 9 +--- tests/test_parser_edges.py | 42 ++++----------- tests/test_scenario.py | 11 ++-- tests/test_server.py | 15 +++++- tests/test_vcr.py | 6 +-- 17 files changed, 256 insertions(+), 182 deletions(-) diff --git a/src/apighost/__main__.py b/src/apighost/__main__.py index 317eafb..8754f70 100644 --- a/src/apighost/__main__.py +++ b/src/apighost/__main__.py @@ -1,4 +1,5 @@ """Enable `python -m apighost` CLI execution.""" + from apighost.cli import cli if __name__ == "__main__": diff --git a/src/apighost/cli.py b/src/apighost/cli.py index 4d7a000..c9e537a 100644 --- a/src/apighost/cli.py +++ b/src/apighost/cli.py @@ -97,6 +97,7 @@ def serve(spec, port, host, scenario, record, cassette_name, latency, watch): # Run with WSGI try: from werkzeug.serving import run_simple + run_simple(host, port, app, use_reloader=False, threaded=True) except KeyboardInterrupt: _on_shutdown(recorder, name, spec) @@ -130,6 +131,7 @@ def record(spec, output, port, requests): # Start server in background from werkzeug.serving import run_simple + thread = threading.Thread( target=lambda: run_simple("127.0.0.1", port, app, use_reloader=False), daemon=True, @@ -199,8 +201,7 @@ def _replay_handler(path): full_path = "/" + path for interaction in cassette_data.interactions: req_path = interaction.request_path.rstrip("/") - if (interaction.request_method == flask_request.method - and req_path == full_path.rstrip("/")): + if interaction.request_method == flask_request.method and req_path == full_path.rstrip("/"): return ( interaction.response_body, interaction.response_status, @@ -211,17 +212,19 @@ def _replay_handler(path): @app.route("/") def _replay_home(): - return jsonify({ - "service": f"APIGhost Replay - {cassette_data.name}", - "interactions": len(cassette_data.interactions), - "endpoints": list(set(f"{i.request_method} {i.request_path}" - for i in cassette_data.interactions)), - }) + return jsonify( + { + "service": f"APIGhost Replay - {cassette_data.name}", + "interactions": len(cassette_data.interactions), + "endpoints": list(set(f"{i.request_method} {i.request_path}" for i in cassette_data.interactions)), + } + ) click.echo(f"\n Replay server at http://{host}:{port}") click.echo(" Press Ctrl+C to stop\n") from werkzeug.serving import run_simple + try: run_simple(host, port, app, use_reloader=False, threaded=True) except KeyboardInterrupt: @@ -363,9 +366,9 @@ def generate(spec, output, name, max_endpoints): for ep in endpoints: key = f"{ep.method} {ep.path}" - body = generate_value( - next(iter(ep.responses.values())).schema_ref if ep.responses else None - ) or {"message": f"Auto-generated response for {key}"} + body = generate_value(next(iter(ep.responses.values())).schema_ref if ep.responses else None) or { + "message": f"Auto-generated response for {key}" + } overrides[key] = {"status": 200, "body": body} click.echo(f" {ep.method:<6} {ep.path}") @@ -389,4 +392,3 @@ def info(): if __name__ == "__main__": cli() - diff --git a/src/apighost/faker_utils.py b/src/apighost/faker_utils.py index 8745d56..050dceb 100644 --- a/src/apighost/faker_utils.py +++ b/src/apighost/faker_utils.py @@ -23,8 +23,8 @@ "phone": lambda: _faker.phone_number(), "int64": lambda: _faker.random_int(min=0, max=10**12), "int32": lambda: _faker.random_int(min=0, max=2**31 - 1), - "float": lambda: round(_faker.pyfloat(min_value=-10**6, max_value=10**6), 2), - "double": lambda: _faker.pyfloat(min_value=-10**12, max_value=10**12), + "float": lambda: round(_faker.pyfloat(min_value=-(10**6), max_value=10**6), 2), + "double": lambda: _faker.pyfloat(min_value=-(10**12), max_value=10**12), "binary": lambda: _faker.binary(16).hex(), "byte": lambda: _faker.binary(8).hex(), "password": lambda: _faker.password(), @@ -115,7 +115,7 @@ def generate_value(schema: dict | None, property_name: str = "") -> Any: return _faker.random_int(min=minimum, max=maximum) if schema_type == "number": - return round(_faker.pyfloat(min_value=-10**6, max_value=10**6), 2) + return round(_faker.pyfloat(min_value=-(10**6), max_value=10**6), 2) if schema_type == "boolean": return _faker.boolean() diff --git a/src/apighost/parser.py b/src/apighost/parser.py index bd0be93..e41e794 100644 --- a/src/apighost/parser.py +++ b/src/apighost/parser.py @@ -54,10 +54,7 @@ def _resolve_schema_refs(schema: dict | None, spec: dict) -> dict | None: elif isinstance(value, dict): resolved[key] = _resolve_schema_refs(value, spec) elif isinstance(value, list): - resolved[key] = [ - _resolve_schema_refs(item, spec) if isinstance(item, dict) else item - for item in value - ] + resolved[key] = [_resolve_schema_refs(item, spec) if isinstance(item, dict) else item for item in value] else: resolved[key] = value return resolved @@ -75,13 +72,15 @@ def _parse_parameters(path_item: dict, path: str, spec: dict) -> list[Parameter] name = resolved.get("name", "") if name not in seen: seen.add(name) - params.append(Parameter( - name=name, - location=resolved.get("in", "query"), - required=resolved.get("required", False), - schema_ref=resolved.get("schema", {}), - example=resolved.get("example") or resolved.get("schema", {}).get("example"), - )) + params.append( + Parameter( + name=name, + location=resolved.get("in", "query"), + required=resolved.get("required", False), + schema_ref=resolved.get("schema", {}), + example=resolved.get("example") or resolved.get("schema", {}).get("example"), + ) + ) return params @@ -188,9 +187,7 @@ def parse_spec(path: str | Path) -> ApiSpec: content = rb.get("content", {}) if content: content_type = list(content.keys())[0] - endpoint.request_body_schema = _resolve_schema_refs( - content[content_type].get("schema", {}), raw - ) + endpoint.request_body_schema = _resolve_schema_refs(content[content_type].get("schema", {}), raw) spec.endpoints.append(endpoint) diff --git a/src/apighost/scenario.py b/src/apighost/scenario.py index 036ccd1..6843b24 100644 --- a/src/apighost/scenario.py +++ b/src/apighost/scenario.py @@ -22,20 +22,22 @@ def list_scenarios() -> list[dict]: for f in sorted(SCENARIO_DIR.glob("*.json")): try: data = json.loads(f.read_text()) - scenarios.append({ - "name": data.get("name", f.stem), - "description": data.get("description", ""), - "overrides": len(data.get("overrides", {})), - "path": str(f), - }) + scenarios.append( + { + "name": data.get("name", f.stem), + "description": data.get("description", ""), + "overrides": len(data.get("overrides", {})), + "path": str(f), + } + ) except (json.JSONDecodeError, OSError): continue return scenarios -def save_scenario(name: str, description: str = "", - overrides: dict[str, dict] | None = None, - output_path: str | None = None) -> str: +def save_scenario( + name: str, description: str = "", overrides: dict[str, dict] | None = None, output_path: str | None = None +) -> str: """Save a scenario definition. Args: diff --git a/src/apighost/schema.py b/src/apighost/schema.py index 8549638..f6872a8 100644 --- a/src/apighost/schema.py +++ b/src/apighost/schema.py @@ -9,6 +9,7 @@ @dataclass class Parameter: """An API parameter (path, query, header, cookie).""" + name: str location: str # path, query, header, cookie required: bool = False @@ -19,6 +20,7 @@ class Parameter: @dataclass class Response: """An API response definition.""" + status_code: int content_type: str = "application/json" schema_ref: dict | None = None @@ -29,6 +31,7 @@ class Response: @dataclass class Endpoint: """A single API endpoint parsed from an OpenAPI spec.""" + path: str method: str # GET, POST, PUT, DELETE, PATCH operation_id: str = "" @@ -45,6 +48,7 @@ class Endpoint: @dataclass class ApiSpec: """Top-level parsed OpenAPI specification.""" + title: str = "" version: str = "" description: str = "" @@ -57,6 +61,7 @@ class ApiSpec: @dataclass class CassetteInteraction: """A single recorded HTTP interaction.""" + request_method: str request_path: str request_headers: dict @@ -69,6 +74,7 @@ class CassetteInteraction: @dataclass class Cassette: """VCR-style cassette of recorded interactions.""" + name: str interactions: list[CassetteInteraction] = field(default_factory=list) spec_path: str = "" @@ -77,6 +83,7 @@ class Cassette: @dataclass class Scenario: """A named scenario with preset responses.""" + name: str description: str = "" overrides: dict[str, dict] = field(default_factory=dict) diff --git a/src/apighost/server.py b/src/apighost/server.py index c00f13f..8b7cdc9 100644 --- a/src/apighost/server.py +++ b/src/apighost/server.py @@ -37,9 +37,9 @@ def _make_response(status: int, body: Any, content_type: str = "application/json return jsonify(body), status -def _build_response_for_endpoint(endpoint: Endpoint, - scenario: Scenario | None = None, - params: dict[str, str] | None = None) -> tuple[Any, int]: +def _build_response_for_endpoint( + endpoint: Endpoint, scenario: Scenario | None = None, params: dict[str, str] | None = None +) -> tuple[Any, int]: """Build a realistic response for an endpoint, respecting scenario overrides.""" key = f"{endpoint.method} {endpoint.path}" @@ -66,8 +66,9 @@ def _build_response_for_endpoint(endpoint: Endpoint, return {"message": f"{endpoint.method} {endpoint.path} — mock response"}, status -def create_app(spec: ApiSpec, scenario: Scenario | None = None, - recorder: Any = None, latency_range: tuple[float, float] = (0, 0)) -> Flask: +def create_app( + spec: ApiSpec, scenario: Scenario | None = None, recorder: Any = None, latency_range: tuple[float, float] = (0, 0) +) -> Flask: """Create a Flask app from a parsed OpenAPI spec.""" app = Flask(__name__) @@ -80,14 +81,16 @@ def create_app(spec: ApiSpec, scenario: Scenario | None = None, @app.route("/") def _apighost_home(): """Generated API home — list available routes.""" - return jsonify({ - "service": spec.title or "APIGhost Mock Server", - "version": spec.version, - "servers": spec.servers, - "description": spec.description or "Mock API server generated from OpenAPI spec", - "endpoints": routes, - "scenario": scenario.name if scenario else "default", - }) + return jsonify( + { + "service": spec.title or "APIGhost Mock Server", + "version": spec.version, + "servers": spec.servers, + "description": spec.description or "Mock API server generated from OpenAPI spec", + "endpoints": routes, + "scenario": scenario.name if scenario else "default", + } + ) @app.route("/_apighost/health") def _apighost_health(): @@ -140,6 +143,7 @@ def handler(**path_params): ) return _make_response(status, body) + return handler app.add_url_rule( diff --git a/src/apighost/vcr.py b/src/apighost/vcr.py index a082cf0..4542078 100644 --- a/src/apighost/vcr.py +++ b/src/apighost/vcr.py @@ -24,13 +24,15 @@ def list_cassettes() -> list[dict]: for f in sorted(CASSETTE_DIR.glob("*.json")): try: data = json.loads(f.read_text()) - cassettes.append({ - "name": f.stem, - "path": str(f), - "interactions": len(data.get("interactions", [])), - "spec": data.get("spec", ""), - "size": f.stat().st_size, - }) + cassettes.append( + { + "name": f.stem, + "path": str(f), + "interactions": len(data.get("interactions", [])), + "spec": data.get("spec", ""), + "size": f.stat().st_size, + } + ) except (json.JSONDecodeError, OSError): continue return cassettes @@ -103,24 +105,35 @@ class Recorder: def __init__(self): self.interactions: list[CassetteInteraction] = [] - def record(self, request_method: str, request_path: str, - request_headers: dict, request_body: str | None, - response_status: int, response_headers: dict, - response_body: str) -> None: + def record( + self, + request_method: str, + request_path: str, + request_headers: dict, + request_body: str | None, + response_status: int, + response_headers: dict, + response_body: str, + ) -> None: """Record a single HTTP interaction.""" # Strip sensitive headers - safe_headers = {k: v for k, v in request_headers.items() - if k.lower() not in ("authorization", "cookie", "set-cookie", "x-api-key")} - - self.interactions.append(CassetteInteraction( - request_method=request_method, - request_path=request_path, - request_headers=safe_headers, - request_body=request_body, - response_status=response_status, - response_headers=response_headers, - response_body=response_body, - )) + safe_headers = { + k: v + for k, v in request_headers.items() + if k.lower() not in ("authorization", "cookie", "set-cookie", "x-api-key") + } + + self.interactions.append( + CassetteInteraction( + request_method=request_method, + request_path=request_path, + request_headers=safe_headers, + request_body=request_body, + response_status=response_status, + response_headers=response_headers, + response_body=response_body, + ) + ) def save(self, name: str, spec_path: str = "") -> str: """Save recorded interactions.""" diff --git a/tests/conftest.py b/tests/conftest.py index 0943638..7b0adcf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,14 +7,17 @@ calls sys.exit(1) when the free-tier daily cap is reached. We replace it with a stub that always returns a LicenseStatus indicating the tool is allowed. """ + import sys from unittest.mock import MagicMock # --- Build a mock module that matches the real package API --- + # Stub LicenseStatus that always reports "allowed" class _MockLicenseStatus: """Mimics revenueholdings_license.LicenseStatus for test env.""" + def __init__(self, tool_name="apighost"): self.allowed = True self.limited = False @@ -23,6 +26,7 @@ def __init__(self, tool_name="apighost"): self.error = None self.tier = "pro" + _mock_module = MagicMock() # Key functions that the CLI calls — must return correct types diff --git a/tests/test_cli.py b/tests/test_cli.py index 4f6ab89..a4a612c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -159,20 +159,35 @@ def test_cli_scenario_create_no_description(self, runner): def test_cli_scenario_edit(self, runner): """Test 'apighost scenario edit'.""" - result = runner.invoke(cli, [ - "scenario", "edit", self.SCENARIO_NAME, - "GET /pets", "--status", "404", - "--body", '{"error":"not found"}', - ]) + result = runner.invoke( + cli, + [ + "scenario", + "edit", + self.SCENARIO_NAME, + "GET /pets", + "--status", + "404", + "--body", + '{"error":"not found"}', + ], + ) assert result.exit_code == 0 assert self.SCENARIO_NAME in result.output def test_cli_scenario_edit_missing(self, runner): """Test 'apighost scenario edit' with nonexistent scenario.""" - result = runner.invoke(cli, [ - "scenario", "edit", "nonexistent-scenario-xyz", - "GET /test", "--status", "200", - ]) + result = runner.invoke( + cli, + [ + "scenario", + "edit", + "nonexistent-scenario-xyz", + "GET /test", + "--status", + "200", + ], + ) assert result.exit_code != 0 assert "not found" in result.output.lower() @@ -228,9 +243,12 @@ def test_main_module_version(self): """Test `python -m apighost --version` outputs version.""" import subprocess import sys + result = subprocess.run( [sys.executable, "-m", "apighost", "--version"], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) assert result.returncode == 0 assert "apighost" in result.stdout @@ -239,9 +257,12 @@ def test_main_module_help(self): """Test `python -m apighost --help` lists commands.""" import subprocess import sys + result = subprocess.run( [sys.executable, "-m", "apighost", "--help"], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) assert result.returncode == 0 assert "serve" in result.stdout @@ -257,7 +278,9 @@ def test_cli_generate_default_all_endpoints(self, runner): assert result.exit_code == 0 # Petstore fixture has 5 endpoints, all should be listed lines = [line.strip() for line in result.output.split("\n") if line.strip()] - endpoint_lines = [line for line in lines if line.startswith("GET") or line.startswith("POST") or line.startswith("DELETE")] + endpoint_lines = [ + line for line in lines if line.startswith("GET") or line.startswith("POST") or line.startswith("DELETE") + ] assert len(endpoint_lines) == 5 assert "5 endpoint responses" in result.output @@ -266,7 +289,9 @@ def test_cli_generate_with_max_endpoints(self, runner): result = runner.invoke(cli, ["generate", PETSTORE_YAML, "--max-endpoints", "2"]) assert result.exit_code == 0 lines = [line.strip() for line in result.output.split("\n") if line.strip()] - endpoint_lines = [line for line in lines if line.startswith("GET") or line.startswith("POST") or line.startswith("DELETE")] + endpoint_lines = [ + line for line in lines if line.startswith("GET") or line.startswith("POST") or line.startswith("DELETE") + ] assert len(endpoint_lines) == 2 assert "2 endpoint responses" in result.output assert "limiting to 2/5" in result.output @@ -291,14 +316,21 @@ class TestGenerateOutput: def test_cli_generate_with_output(self, runner, tmp_path): """Test 'apighost generate' with custom output path.""" output_file = tmp_path / "custom-gen.json" - result = runner.invoke(cli, [ - "generate", PETSTORE_YAML, - "-o", str(output_file), - "-n", "output-test", - ]) + result = runner.invoke( + cli, + [ + "generate", + PETSTORE_YAML, + "-o", + str(output_file), + "-n", + "output-test", + ], + ) assert result.exit_code == 0 assert output_file.exists(), "Output file should be written to custom path" import json + data = json.loads(output_file.read_text()) assert data["name"] == "output-test" assert "overrides" in data @@ -307,11 +339,17 @@ def test_cli_generate_output_directory_created(self, runner, tmp_path): """Test 'apighost generate --output' creates parent dirs.""" nested_dir = tmp_path / "sub" / "dir" nested_file = nested_dir / "gen.json" - result = runner.invoke(cli, [ - "generate", PETSTORE_YAML, - "-o", str(nested_file), - "-n", "nested-gen", - ]) + result = runner.invoke( + cli, + [ + "generate", + PETSTORE_YAML, + "-o", + str(nested_file), + "-n", + "nested-gen", + ], + ) assert result.exit_code == 0 assert nested_file.exists(), "Parent dirs should be auto-created" @@ -324,10 +362,19 @@ class TestScenarioEditRawBody: def test_cli_scenario_edit_raw_string_body(self, runner): """Test editing scenario with a raw string body (not JSON).""" runner.invoke(cli, ["scenario", "create", self.SCENARIO_NAME, "-d", "Raw body test"]) - result = runner.invoke(cli, [ - "scenario", "edit", self.SCENARIO_NAME, - "GET /raw", "--status", "200", "--body", "plain text body", - ]) + result = runner.invoke( + cli, + [ + "scenario", + "edit", + self.SCENARIO_NAME, + "GET /raw", + "--status", + "200", + "--body", + "plain text body", + ], + ) assert result.exit_code == 0 assert self.SCENARIO_NAME in result.output runner.invoke(cli, ["scenario", "delete", self.SCENARIO_NAME]) @@ -340,6 +387,7 @@ def test_main_module_runpy_version(self): """Test __main__.py via runpy triggers CLI --version.""" import runpy import sys + saved_argv = sys.argv try: sys.argv = ["apighost", "--version"] diff --git a/tests/test_cli_record.py b/tests/test_cli_record.py index 5280721..22f205d 100644 --- a/tests/test_cli_record.py +++ b/tests/test_cli_record.py @@ -175,9 +175,11 @@ def test_replay_home_route(self, mock_run, runner): # Capture the Flask app created inside replay() captured_app = None + def capture_app(host, port, app, **kwargs): nonlocal captured_app captured_app = app + mock_run.side_effect = capture_app result = runner.invoke(cli, ["replay", "replay-route-test"]) @@ -211,9 +213,11 @@ def test_replay_handler_match(self, mock_run, runner): save_cassette("replay-route-test", [interaction], None) captured_app = None + def capture_app(host, port, app, **kwargs): nonlocal captured_app captured_app = app + mock_run.side_effect = capture_app result = runner.invoke(cli, ["replay", "replay-route-test"]) @@ -242,9 +246,11 @@ def test_replay_handler_no_match(self, mock_run, runner): save_cassette("replay-route-test", [interaction], None) captured_app = None + def capture_app(host, port, app, **kwargs): nonlocal captured_app captured_app = app + mock_run.side_effect = capture_app result = runner.invoke(cli, ["replay", "replay-route-test"]) @@ -273,9 +279,11 @@ def test_replay_handler_method_mismatch(self, mock_run, runner): save_cassette("replay-route-test", [interaction], None) captured_app = None + def capture_app(host, port, app, **kwargs): nonlocal captured_app captured_app = app + mock_run.side_effect = capture_app result = runner.invoke(cli, ["replay", "replay-route-test"]) @@ -303,9 +311,11 @@ def test_replay_handler_no_headers_fallback(self, mock_run, runner): save_cassette("replay-route-test", [interaction], None) captured_app = None + def capture_app(host, port, app, **kwargs): nonlocal captured_app captured_app = app + mock_run.side_effect = capture_app result = runner.invoke(cli, ["replay", "replay-route-test"]) diff --git a/tests/test_faker_utils.py b/tests/test_faker_utils.py index cf702a4..fd4aef0 100644 --- a/tests/test_faker_utils.py +++ b/tests/test_faker_utils.py @@ -32,13 +32,15 @@ def test_generate_array(): def test_generate_object(): """Test generating an object from properties.""" - val = generate_value({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"}, + val = generate_value( + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, } - }) + ) assert isinstance(val, dict) assert "name" in val assert "age" in val @@ -73,21 +75,23 @@ def test_generate_by_property_name(): def test_generate_nested(): """Test nested object generation.""" - val = generate_value({ - "type": "object", - "properties": { - "user": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "contacts": { - "type": "array", - "items": {"type": "string", "format": "email"}, - } + val = generate_value( + { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "contacts": { + "type": "array", + "items": {"type": "string", "format": "email"}, + }, + }, } - } + }, } - }) + ) assert isinstance(val["user"]["name"], str) assert isinstance(val["user"]["contacts"], list) assert "@" in val["user"]["contacts"][0] @@ -141,12 +145,7 @@ def test_generate_number(): def test_generate_array_with_bounds(): """Test array with minItems/maxItems constraints.""" - val = generate_value({ - "type": "array", - "items": {"type": "string"}, - "minItems": 2, - "maxItems": 4 - }) + val = generate_value({"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 4}) assert isinstance(val, list) assert 2 <= len(val) <= 4 assert all(isinstance(v, str) for v in val) @@ -154,13 +153,15 @@ def test_generate_array_with_bounds(): def test_generate_object_required_not_in_properties(): """Test object where required field is not listed in properties.""" - val = generate_value({ - "type": "object", - "properties": { - "name": {"type": "string"}, - }, - "required": ["name", "extra_field"] - }) + val = generate_value( + { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + "required": ["name", "extra_field"], + } + ) assert isinstance(val, dict) assert "name" in val assert "extra_field" in val diff --git a/tests/test_parser.py b/tests/test_parser.py index 55e76f0..337f220 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -75,14 +75,7 @@ def test_parse_json_spec(): spec_dict = { "openapi": "3.0.0", "info": {"title": "JSON Test API", "version": "1.0.0"}, - "paths": { - "/items": { - "get": { - "operationId": "listItems", - "responses": {"200": {"description": "OK"}} - } - } - } + "paths": {"/items": {"get": {"operationId": "listItems", "responses": {"200": {"description": "OK"}}}}}, } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(spec_dict, f) diff --git a/tests/test_parser_edges.py b/tests/test_parser_edges.py index ecce7d7..5578b29 100644 --- a/tests/test_parser_edges.py +++ b/tests/test_parser_edges.py @@ -83,13 +83,7 @@ def test_resolve_schema_refs_nested_ref(): def test_resolve_schema_refs_list_with_dicts(): """Resolves refs inside list items that are dicts.""" - spec = { - "components": { - "schemas": { - "Tag": {"type": "string"} - } - } - } + spec = {"components": {"schemas": {"Tag": {"type": "string"}}}} schema = { "type": "array", "items": [{"$ref": "#/components/schemas/Tag"}, {"type": "integer"}], @@ -123,9 +117,7 @@ def test_parse_parameters_with_ref(): } } } - path_item = { - "parameters": [{"$ref": "#/components/parameters/limitParam"}] - } + path_item = {"parameters": [{"$ref": "#/components/parameters/limitParam"}]} result = _parse_parameters(path_item, "/items", spec) assert len(result) == 1 assert result[0].name == "limit" @@ -156,9 +148,7 @@ def test_parse_parameters_no_params(): def test_parse_responses_default_status(): """'default' response maps to status code 0 (wildcard).""" - responses_obj = { - "default": {"description": "Unexpected error"} - } + responses_obj = {"default": {"description": "Unexpected error"}} result = _parse_responses(responses_obj, {}) assert 0 in result assert result[0].description == "Unexpected error" @@ -187,9 +177,7 @@ def test_parse_responses_with_ref(): } } } - responses_obj = { - "404": {"$ref": "#/components/responses/NotFound"} - } + responses_obj = {"404": {"$ref": "#/components/responses/NotFound"}} result = _parse_responses(responses_obj, spec) assert 404 in result assert result[404].description == "Not found" @@ -256,9 +244,7 @@ def test_extract_example_no_match(): def test_load_spec_json(): """Load a JSON spec file.""" spec_data = {"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}} - with tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(spec_data, f) tmp = f.name try: @@ -273,9 +259,7 @@ def test_load_spec_yaml(): import yaml spec_data = {"openapi": "3.0.0", "info": {"title": "YAML Test", "version": "1.0.0"}} - with tempfile.NamedTemporaryFile( - mode="w", suffix=".yml", delete=False - ) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: yaml.dump(spec_data, f) tmp = f.name try: @@ -305,9 +289,7 @@ def test_get_param_pattern_multiple_params(): def test_parse_spec_minimal(): """Parse a minimal spec with no paths.""" spec_data = {"openapi": "3.0.0", "info": {"title": "Empty", "version": "0.0.0"}} - with tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(spec_data, f) tmp = f.name try: @@ -325,9 +307,7 @@ def test_parse_spec_shared_parameters(): "info": {"title": "Test", "version": "1.0.0"}, "paths": { "/items/{itemId}": { - "parameters": [ - {"name": "itemId", "in": "path", "required": True, "schema": {"type": "string"}} - ], + "parameters": [{"name": "itemId", "in": "path", "required": True, "schema": {"type": "string"}}], "get": { "operationId": "getItem", "responses": {"200": {"description": "OK"}}, @@ -339,9 +319,7 @@ def test_parse_spec_shared_parameters(): } }, } - with tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(spec_data, f) tmp = f.name try: @@ -383,5 +361,3 @@ def test_parse_spec_request_body_with_ref(tmp_path): result = parse_spec(str(path)) assert len(result.endpoints) == 1 assert result.endpoints[0].request_body_schema == {"type": "object"} - - diff --git a/tests/test_scenario.py b/tests/test_scenario.py index de18251..eb1454d 100644 --- a/tests/test_scenario.py +++ b/tests/test_scenario.py @@ -6,9 +6,13 @@ def test_save_and_load_scenario(): """Test roundtrip save/load scenario.""" - path = save_scenario("test-scenario", "A test scenario", { - "GET /users": {"status": 404, "body": {"error": "not found"}}, - }) + path = save_scenario( + "test-scenario", + "A test scenario", + { + "GET /users": {"status": 404, "body": {"error": "not found"}}, + }, + ) assert path is not None sc = load_scenario("test-scenario") @@ -44,6 +48,7 @@ def test_load_nonexistent_scenario(): def test_list_scenarios_skips_corrupted_json(): """list_scenarios skips files with invalid JSON (covers lines 31-32).""" from apighost.scenario import SCENARIO_DIR + save_scenario("good-scenario", "valid") # Write corrupted JSON bad_file = SCENARIO_DIR / "corrupted.json" diff --git a/tests/test_server.py b/tests/test_server.py index 5c41a16..8b9d482 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -148,6 +148,7 @@ def test_head_request(petstore_spec): def test_latency_zero_no_delay(petstore_spec): """Test that zero latency (default) adds no delay.""" import time + app = create_app(petstore_spec, latency_range=(0, 0)) client = app.test_client() start = time.monotonic() @@ -160,6 +161,7 @@ def test_latency_zero_no_delay(petstore_spec): def test_latency_applies_delay(petstore_spec): """Test that latency_range adds a measurable delay to responses.""" import time + app = create_app(petstore_spec, latency_range=(0.05, 0.1)) client = app.test_client() start = time.monotonic() @@ -171,9 +173,11 @@ def test_latency_applies_delay(petstore_spec): # --- Internal helper tests (coverage gaps) --- + def test_extract_path_params_no_match(): """_extract_path_params returns {} when path doesn't match (covers line 30).""" from apighost.server import _extract_path_params + result = _extract_path_params("/users/{id}", "/items/42") assert result == {} @@ -182,6 +186,7 @@ def test_make_response_with_dict(): """_make_response with dict body returns jsonify tuple (covers line 37).""" from apighost.server import _make_response from flask import Flask + app = Flask(__name__) with app.app_context(): resp = _make_response(201, {"id": 1, "name": "test"}) @@ -193,6 +198,7 @@ def test_make_response_with_dict(): def test_make_response_with_string(): """_make_response with string body returns Response object (covers line 36).""" from apighost.server import _make_response + resp = _make_response(200, "plain text", content_type="text/plain") assert resp.status_code == 200 assert resp.get_data(as_text=True) == "plain text" @@ -202,6 +208,7 @@ def test_build_response_fallback_without_example_or_schema(): """_build_response_for_endpoint fallback when no response_def or schema (covers line 66).""" from apighost.schema import Endpoint from apighost.server import _build_response_for_endpoint + ep = Endpoint(path="/fallback", method="get", responses={}) body, status = _build_response_for_endpoint(ep, scenario=None, params={}) assert status in (200,) @@ -213,9 +220,13 @@ def test_build_response_with_schema_ref(): from apighost.schema import Endpoint from apighost.schema import Response as ApiResponse from apighost.server import _build_response_for_endpoint + ep = Endpoint( - path="/schema-only", method="get", - responses={200: ApiResponse(status_code=200, schema_ref={"type": "object", "properties": {"id": {"type": "integer"}}})} + path="/schema-only", + method="get", + responses={ + 200: ApiResponse(status_code=200, schema_ref={"type": "object", "properties": {"id": {"type": "integer"}}}) + }, ) body, status = _build_response_for_endpoint(ep, scenario=None, params={}) assert status == 200 diff --git a/tests/test_vcr.py b/tests/test_vcr.py index 1f2ce90..fee5151 100644 --- a/tests/test_vcr.py +++ b/tests/test_vcr.py @@ -1,6 +1,5 @@ """Tests for VCR cassette module.""" - import pytest from apighost.vcr import CassetteInteraction, Recorder, load_cassette @@ -35,8 +34,7 @@ def test_recorder_clear(): def test_recorder_strips_sensitive_headers(): """Test that sensitive headers are stripped from recording.""" r = Recorder() - r.record("GET", "/secure", {"Authorization": "Bearer xxx", "X-Custom": "ok"}, - None, 200, {}, "") + r.record("GET", "/secure", {"Authorization": "Bearer xxx", "X-Custom": "ok"}, None, 200, {}, "") recorded = r.interactions[0] assert "authorization" not in recorded.request_headers assert "Authorization" not in recorded.request_headers @@ -81,6 +79,7 @@ def test_cassette_interaction_dataclass(): def test_save_and_load_cassette_with_name(): """Test saving and loading a named cassette via list_cassettes.""" from apighost.vcr import Recorder, list_cassettes, load_cassette, save_cassette + r = Recorder() r.record("GET", "/named", {}, None, 200, {}, '"named"') path = save_cassette("named-cassette", r.interactions, "/path/to/spec.yaml") @@ -97,6 +96,7 @@ def test_save_and_load_cassette_with_name(): def test_list_cassettes_skips_corrupted_json(): """list_cassettes skips files with invalid JSON (covers lines 34-35).""" from apighost.vcr import CASSETTE_DIR, CassetteInteraction, list_cassettes, save_cassette + # Save a valid cassette ci = CassetteInteraction("GET", "/valid", {}, None, 200, {}, '"ok"') save_cassette("valid-cassette", [ci], "") From 31266a1cd9025552737d97ba923dc9794a71ce6c Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 01:45:16 -0400 Subject: [PATCH 08/12] chore: trigger CI for reviewer-A fixes From 398e5af2b49448cb8af95dcd44971475d10ce406 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 01:50:15 -0400 Subject: [PATCH 09/12] chore: trigger CI for reviewer-A fixes --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e79342a..18e128a 100644 --- a/README.md +++ b/README.md @@ -180,3 +180,4 @@ MIT — see [LICENSE](LICENSE) Part of the [Coding-Dev-Tools](https://github.com/Coding-Dev-Tools) ecosystem — a suite of developer CLI tools. Also check out [API Contract Guardian](https://github.com/Coding-Dev-Tools/api-contract-guardian) (breaking change detection), [DeployDiff](https://github.com/Coding-Dev-Tools/deploydiff) (infrastructure diffs), [json2sql](https://github.com/Coding-Dev-Tools/json2sql) (JSON → SQL), [ConfigDrift](https://github.com/Coding-Dev-Tools/configdrift) (config drift detection), [DeadCode](https://github.com/Coding-Dev-Tools/deadcode) (dead code cleanup), [Envault](https://github.com/Coding-Dev-Tools/envault) (env sync), [SchemaForge](https://github.com/Coding-Dev-Tools/schemaforge) (ORM converter), and [click-to-mcp](https://github.com/Coding-Dev-Tools/click-to-mcp) (CLI → MCP server). # CI Trigger +# CI trigger From 7e05be3818994b7c41bdd1661f5a2d57f9a8287a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 01:52:41 -0400 Subject: [PATCH 10/12] chore: force CI trigger for f07f9b8 From 3cbfe27624d4f0408fccee5fb83ac30cb6808f36 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 02:20:03 -0400 Subject: [PATCH 11/12] improve: add ruff to dev dependencies and add py.typed marker for PEP 561 support - Add ruff>=0.4.0 to dev dependencies in pyproject.toml for local linting - Add py.typed marker file for PEP 561 typing support (py.typed marker) - Both changes improve developer experience and package quality --- pyproject.toml | 1 + src/apighost/py.typed | 0 2 files changed, 1 insertion(+) create mode 100644 src/apighost/py.typed diff --git a/pyproject.toml b/pyproject.toml index efe9527..dfdf534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "httpx>=0.27.0", + "ruff>=0.4.0", ] [project.urls] diff --git a/src/apighost/py.typed b/src/apighost/py.typed new file mode 100644 index 0000000..e69de29 From addd556fa11ce7e4c536ab110b29986bef38b56b Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 02:50:11 -0400 Subject: [PATCH 12/12] fix: remove duplicate CI trigger comments from README.md by reviewer-A --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 18e128a..b5be7b1 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,3 @@ MIT — see [LICENSE](LICENSE) --- Part of the [Coding-Dev-Tools](https://github.com/Coding-Dev-Tools) ecosystem — a suite of developer CLI tools. Also check out [API Contract Guardian](https://github.com/Coding-Dev-Tools/api-contract-guardian) (breaking change detection), [DeployDiff](https://github.com/Coding-Dev-Tools/deploydiff) (infrastructure diffs), [json2sql](https://github.com/Coding-Dev-Tools/json2sql) (JSON → SQL), [ConfigDrift](https://github.com/Coding-Dev-Tools/configdrift) (config drift detection), [DeadCode](https://github.com/Coding-Dev-Tools/deadcode) (dead code cleanup), [Envault](https://github.com/Coding-Dev-Tools/envault) (env sync), [SchemaForge](https://github.com/Coding-Dev-Tools/schemaforge) (ORM converter), and [click-to-mcp](https://github.com/Coding-Dev-Tools/click-to-mcp) (CLI → MCP server). - -# CI Trigger -# CI trigger