Regenerate Client #52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Regenerate Client | |
| on: | |
| workflow_dispatch: | |
| jobs: | |
| regenerate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 | |
| with: | |
| app-id: 3060111 | |
| private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }} | |
| owner: hotdata-dev | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 | |
| with: | |
| python-version: '3.12' | |
| - name: Fetch merged OpenAPI spec | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| curl -sS -f -L \ | |
| -H "Accept: application/vnd.github.v3.raw" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/openapi.yaml \ | |
| -o openapi.yaml | |
| - name: Clean existing source | |
| run: rm -rf src/ | |
| - name: Bump package patch version in pyproject.toml | |
| id: pkg | |
| run: | | |
| python3 -m pip install --quiet bump-my-version | |
| current_version=$(python3 -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") | |
| bump-my-version bump patch --current-version "$current_version" --no-commit --no-tag --allow-dirty | |
| version=$(bump-my-version show current_version) | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| - name: Generate client | |
| run: | | |
| npx @openapitools/openapi-generator-cli generate \ | |
| -i openapi.yaml \ | |
| -g python \ | |
| -o . \ | |
| -t .openapi-generator-templates \ | |
| --additional-properties=packageName=hotdata,projectName=hotdata,packageVersion=${{ steps.pkg.outputs.version }},gitUserId=hotdata-dev,gitRepoId=sdk-python \ | |
| --skip-validate-spec | |
| - name: Patch generated __version__ to read from package metadata | |
| run: | | |
| python3 - <<'PY' | |
| import re, pathlib, sys | |
| p = pathlib.Path("hotdata/__init__.py") | |
| src = p.read_text() | |
| replacement = ( | |
| 'from importlib.metadata import PackageNotFoundError, version as _pkg_version\n' | |
| '\n' | |
| 'try:\n' | |
| ' __version__ = _pkg_version("hotdata")\n' | |
| 'except PackageNotFoundError: # running from a source checkout without install\n' | |
| ' __version__ = "0.0.0+unknown"\n' | |
| ) | |
| new, n = re.subn(r'^__version__ = "[^"]*"\n', replacement, src, count=1, flags=re.MULTILINE) | |
| if n != 1: | |
| sys.exit("Failed to patch __version__ line in hotdata/__init__.py") | |
| p.write_text(new) | |
| PY | |
| - name: Patch generated pyproject.toml metadata | |
| run: | | |
| python3 - <<'PY' | |
| import re, pathlib, sys | |
| p = pathlib.Path("pyproject.toml") | |
| src = p.read_text() | |
| patches = [ | |
| ( | |
| r'^keywords = \[.*\]$', | |
| 'keywords = ["hotdata", "api-client", "data-platform"]', | |
| re.MULTILINE, | |
| ), | |
| # Insert [project.optional-dependencies] (for hotdata.arrow) just | |
| # before [project.urls]. Run before the urls patch so the urls | |
| # anchor is unchanged when this fires. | |
| ( | |
| r'(\ndependencies = \[\n(?:[^\]]|\][^\n])*\]\n)\n(\[project\.urls\])', | |
| r'\1\n[project.optional-dependencies]\narrow = ["pyarrow >= 14"]\n\n\2', | |
| 0, | |
| ), | |
| ( | |
| r'\[project\.urls\]\nRepository = "[^"]*"\n', | |
| '[project.urls]\nHomepage = "https://www.hotdata.dev"\nRepository = "https://github.com/hotdata-dev/sdk-python"\n', | |
| 0, | |
| ), | |
| ] | |
| for pattern, replacement, flags in patches: | |
| src, n = re.subn(pattern, replacement, src, count=1, flags=flags) | |
| if n != 1: | |
| sys.exit(f"Failed to patch pyproject.toml: pattern {pattern!r}") | |
| p.write_text(src) | |
| PY | |
| - name: Patch ApiClient close lifecycle | |
| run: python3 scripts/patch_api_client_close.py | |
| - name: Verify JWT-exchange code survived regeneration | |
| run: | | |
| python3 - <<'PY' | |
| import ast, pathlib, sys | |
| errors = [] | |
| # 1. The hand-written, regen-immune auth module must survive. | |
| if not pathlib.Path("hotdata/_auth.py").is_file(): | |
| errors.append("hotdata/_auth.py is missing (regen overwrote/dropped it)") | |
| config = pathlib.Path("hotdata/configuration.py") | |
| if not config.is_file(): | |
| errors.append("hotdata/configuration.py is missing") | |
| else: | |
| tree = ast.parse(config.read_text()) | |
| cls = next( | |
| (n for n in tree.body | |
| if isinstance(n, ast.ClassDef) and n.name == "Configuration"), | |
| None, | |
| ) | |
| if cls is None: | |
| errors.append("Configuration class not found in configuration.py") | |
| else: | |
| # 2. api_key must be a property (decorated getter), so every | |
| # request transparently exchanges for a fresh JWT. | |
| api_key_is_property = any( | |
| isinstance(n, ast.FunctionDef) | |
| and n.name == "api_key" | |
| and any( | |
| isinstance(d, ast.Name) and d.id == "property" | |
| for d in n.decorator_list | |
| ) | |
| for n in cls.body | |
| ) | |
| if not api_key_is_property: | |
| errors.append("Configuration.api_key is not a @property (template drift)") | |
| # 3. The token manager must be created eagerly in __init__ | |
| # (lazy creation has a concurrent-first-request race). | |
| init = next( | |
| (n for n in cls.body | |
| if isinstance(n, ast.FunctionDef) and n.name == "__init__"), | |
| None, | |
| ) | |
| init_src = ast.get_source_segment(config.read_text(), init) if init else "" | |
| if "self._token_manager = _TokenManager(" not in (init_src or ""): | |
| errors.append("eager self._token_manager assignment missing from __init__") | |
| # 4. __deepcopy__ must skip _token_manager (lock + PoolManager | |
| # are not deepcopy-able) and rebuild it. | |
| deepcopy = next( | |
| (n for n in cls.body | |
| if isinstance(n, ast.FunctionDef) and n.name == "__deepcopy__"), | |
| None, | |
| ) | |
| if deepcopy is None: | |
| errors.append("__deepcopy__ missing from Configuration") | |
| else: | |
| # Look for _token_manager as a real identifier/string in the | |
| # body (AST, so comments mentioning it don't count) — proves | |
| # the lock/PoolManager skip-and-rebuild actually survived. | |
| refs = any( | |
| (isinstance(n, ast.Constant) and n.value == "_token_manager") | |
| or (isinstance(n, ast.Attribute) and n.attr == "_token_manager") | |
| for n in ast.walk(deepcopy) | |
| ) | |
| if not refs: | |
| errors.append("__deepcopy__ does not skip/rebuild _token_manager") | |
| if errors: | |
| print("::error::JWT-exchange regen-safety check failed:") | |
| for e in errors: | |
| print(f" - {e}") | |
| sys.exit(1) | |
| print("JWT-exchange code survived regeneration: " | |
| "_auth.py present, api_key property, eager _token_manager, " | |
| "__deepcopy__ handling all intact.") | |
| PY | |
| - name: Clean up generated artifacts | |
| run: | | |
| rm -f openapi.yaml | |
| rm -f .github/workflows/python.yml | |
| - name: Verify built wheel installs and imports | |
| run: | | |
| python -m pip install --upgrade build | |
| python -m build | |
| python -m pip install dist/*.whl | |
| # cd away from the source tree so the import resolves against the installed wheel. | |
| cd /tmp && python -c "import hotdata; print(hotdata.__version__)" | |
| - name: Check integration test scenario parity | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| curl -sS -f -L \ | |
| -H "Accept: application/vnd.github.v3.raw" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \ | |
| -o test-scenarios.yaml | |
| pip install --quiet pyyaml | |
| python3 - <<'PY' | |
| import sys, pathlib, yaml | |
| scenarios = yaml.safe_load(pathlib.Path("test-scenarios.yaml").read_text())["scenarios"] | |
| missing = [] | |
| for s in scenarios: | |
| if "python" in (s.get("optional_for") or []): | |
| continue | |
| expected = pathlib.Path("tests/integration") / f"test_{s['name']}.py" | |
| if not expected.exists(): | |
| missing.append(str(expected)) | |
| if missing: | |
| print(f"::warning::sdk-python is missing tests for {len(missing)} scenarios after regen:") | |
| for m in missing: | |
| print(f" - {m}") | |
| else: | |
| print(f"All {len(scenarios)} scenarios have corresponding test files.") | |
| PY | |
| rm -f test-scenarios.yaml | |
| - name: Create PR | |
| id: cpr | |
| uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| title: "chore: regenerate client from updated OpenAPI spec" | |
| branch: openapi-update-${{ github.run_id }} | |
| commit-message: "chore: regenerate client from OpenAPI spec" | |
| body: "Auto-generated from updated HotData OpenAPI spec." | |
| # Enable native auto-merge (squash). Branch protection on main gates the | |
| # merge on the test checks (scenario-parity, integration) plus the org | |
| # Claude review check and its approving review, so this only merges once | |
| # everything is green and Claude has approved. | |
| - name: Enable auto-merge | |
| if: steps.cpr.outputs.pull-request-number | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| gh pr merge "${{ steps.cpr.outputs.pull-request-number }}" \ | |
| --repo "${{ github.repository }}" \ | |
| --squash --auto |