diff --git a/.gitignore b/.gitignore index 9416b0b4..7c481a92 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,15 @@ var/ .installed.cfg *.egg +# Poetry is not the supported install path for this project (build-backend +# is setuptools; CI uses `pip install -r requirements.txt`). A committed +# poetry.lock creates a two-tier dependency-resolution story - pip users +# get version ranges from pyproject.toml/requirements.txt while Poetry +# users get pinned versions from the lock file - which can drift. If +# Poetry adoption is ever formalised, that migration belongs in a +# dedicated PR (with [tool.poetry] sections and CI updates). +poetry.lock + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. @@ -61,4 +70,10 @@ docs/_build/ .ipynb_checkpoints .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# Local environment files (secrets, never commit) +.env +.env.* +!.env.example +!.env.sample \ No newline at end of file diff --git a/_sdk_generator_utils.py b/_sdk_generator_utils.py new file mode 100644 index 00000000..df7de85d --- /dev/null +++ b/_sdk_generator_utils.py @@ -0,0 +1,154 @@ +"""Shared helpers for the OpenAPI generator wrapper scripts. + +Both ``generate_management_sdk.py`` and ``generate_frontend_sdk.py`` need +to do the same handful of version-related operations: resolve the SDK +version from ``kinde_sdk/_version.py``, know the +``"SDK_VERSION"`` placeholder used in the generator config files, and +post-process the OpenAPI-emitted ``__init__.py`` so its ``__version__`` +is imported from ``kinde_sdk._version`` rather than baked in as a +literal. Keeping a single implementation of each here eliminates the +slow drift that was starting to appear between the two scripts (e.g. +diverging type annotations, log message styling, and error handling). + +The module is intentionally named with a leading underscore to signal +that it is internal to this repository's generator tooling; it is not +part of the public ``kinde_sdk`` package and is not shipped as part of +the distribution. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Version resolution +# --------------------------------------------------------------------------- + +# Path to the single source of truth for the SDK version. Both generator +# scripts sit at the repo root next to ``kinde_sdk/``, and so does this +# module - so ``Path(__file__).resolve().parent`` is the repo root. +_VERSION_FILE = Path(__file__).resolve().parent / "kinde_sdk" / "_version.py" + + +def read_sdk_version() -> str: + """Read the canonical SDK version string from ``kinde_sdk/_version.py``. + + The resolved value is supplied to ``openapi-generator-cli`` at + runtime via ``--additional-properties=packageVersion=`` + so that artifacts which embed the version literally (e.g. user-agent + headers in the generated ``configuration.py``) stay in lockstep with + the SDK. The generator config files themselves + (``openapitools.json`` and ``generator/frontend_config.yaml``) + hold the literal placeholder :data:`PACKAGE_VERSION_PLACEHOLDER` so + they cannot become second sources of truth; see + ``testv2/testv2_core/test_version_sync.py`` for the enforcing tests. + + Raises: + RuntimeError: if ``_version.py`` exists but no ``__version__`` + assignment can be parsed out of it. + """ + text = _VERSION_FILE.read_text(encoding="utf-8") + match = re.search( + r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE + ) + if not match: + raise RuntimeError( + f"Could not parse __version__ from {_VERSION_FILE}; " + "the generator can't derive packageVersion." + ) + return match.group(1) + + +#: Resolved SDK version, ready for both generator scripts to use. +SDK_VERSION: str = read_sdk_version() + + +# --------------------------------------------------------------------------- +# Generator-config placeholder +# --------------------------------------------------------------------------- + +#: Literal string written into the ``packageVersion`` field of both +#: generator config files (``openapitools.json`` and +#: ``generator/frontend_config.yaml``). The committed/written value is +#: deliberately *not* a version literal - it is this self-documenting +#: placeholder, so the files cannot be mistaken for a second source of +#: truth. The resolved version is injected at runtime via +#: ``--additional-properties=packageVersion=`` on the +#: ``openapi-generator-cli`` command line, which overrides whatever the +#: file contains. The placeholder invariant is asserted in CI by +#: ``testv2/testv2_core/test_version_sync.py``. +PACKAGE_VERSION_PLACEHOLDER: str = "SDK_VERSION" + + +# --------------------------------------------------------------------------- +# Post-generation ``__version__`` rewrite +# --------------------------------------------------------------------------- + +#: Line written into the generated sub-package ``__init__.py`` in place +#: of the OpenAPI-emitted ``__version__ = "..."`` literal. Holding a +#: single constant here means both generator scripts (and any future +#: ones) emit byte-identical replacements. +DYNAMIC_VERSION_IMPORT: str = ( + "from kinde_sdk._version import __version__ " + "# single source of truth; see kinde_sdk/_version.py" +) + + +_LITERAL_VERSION_LINE = re.compile( + r'^__version__\s*=\s*["\'][^"\']+["\']\s*$', + re.MULTILINE, +) + + +def make_version_dynamic(init_file: Path) -> None: + """Rewrite the ``__version__`` literal in a generated ``__init__.py``. + + Replaces the OpenAPI-emitted ``__version__ = "X"`` line with + :data:`DYNAMIC_VERSION_IMPORT` so the sub-package re-exports the + canonical value from ``kinde_sdk._version`` and can never drift from + the SDK source of truth. + + Idempotent: a no-op if the file already imports ``__version__`` from + ``kinde_sdk._version``. Tolerant of missing files and missing + ``__version__`` lines - logs a warning and returns rather than + raising, because the calling scripts treat this as a best-effort + post-generation step. + + Args: + init_file: Path to the generated sub-package ``__init__.py``. + Always passed as a :class:`pathlib.Path` (callers convert + string paths via :func:`pathlib.Path` at the call site). + """ + if not init_file.exists(): + print(f"⚠️ Cannot rewrite __version__: {init_file} does not exist") + return + + text = init_file.read_text(encoding="utf-8") + + if "from kinde_sdk._version import __version__" in text: + print(f"✓ {init_file} already imports __version__ from kinde_sdk._version") + return + + new_text, n = _LITERAL_VERSION_LINE.subn( + DYNAMIC_VERSION_IMPORT, text, count=1 + ) + if n == 0: + print( + f"⚠️ Could not find a literal __version__ in {init_file} to replace; " + "is the OpenAPI generator template still emitting one?" + ) + return + + init_file.write_text(new_text, encoding="utf-8") + print(f"✓ Rewrote __version__ in {init_file} to import from kinde_sdk._version") + + +__all__ = [ + "DYNAMIC_VERSION_IMPORT", + "PACKAGE_VERSION_PLACEHOLDER", + "SDK_VERSION", + "make_version_dynamic", + "read_sdk_version", +] diff --git a/generate_frontend_sdk.py b/generate_frontend_sdk.py index 9909980c..75dcd78a 100644 --- a/generate_frontend_sdk.py +++ b/generate_frontend_sdk.py @@ -1,8 +1,16 @@ import os +import re import subprocess import sys import shutil import tempfile +from pathlib import Path + +from _sdk_generator_utils import ( + PACKAGE_VERSION_PLACEHOLDER, + SDK_VERSION, + make_version_dynamic as _make_version_dynamic, +) # OpenAPI spec URL from Kinde Frontend API OPENAPI_SPEC_URL = "https://api-spec.kinde.com/kinde-frontend-api-spec.yaml" @@ -10,6 +18,27 @@ GENERATOR_DIR = "generator" CONFIG_FILE = f"{GENERATOR_DIR}/frontend_config.yaml" + +# Re-export under the domain-specific name used in this script's log +# messages, docstrings, and the comments embedded in +# ``generator/frontend_config.yaml``. This is just an alias for +# ``_sdk_generator_utils.PACKAGE_VERSION_PLACEHOLDER`` - the shared +# constant remains the single source of truth, so the two generator +# scripts cannot drift on the placeholder value. +FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER = PACKAGE_VERSION_PLACEHOLDER + + +def make_version_dynamic(init_file) -> None: + """Thin wrapper that accepts ``str`` paths and forwards to the shared util. + + The shared implementation in ``_sdk_generator_utils`` is typed + ``init_file: Path``, but this script historically passed + ``os.path.join(...)`` strings; converting at this single call-site + keeps the public signature stable while still routing all rewrite + logic through one canonical implementation. + """ + _make_version_dynamic(Path(init_file)) + def fix_imports(directory): """Fix import paths in generated Python files.""" print(f"Fixing imports in {directory}") @@ -162,20 +191,37 @@ def restore_custom_files(backup_dir): os.makedirs(GENERATOR_DIR) print(f"Created directory: {GENERATOR_DIR}") -# Create frontend config.yaml if it doesn't exist -if not os.path.isfile(CONFIG_FILE): - config_content = """# openapi-generator-cli config for python frontend API +# Always (re)write the frontend config so its ``packageVersion`` field +# stays the self-documenting ``SDK_VERSION`` placeholder. The resolved +# version is supplied at runtime via ``--additional-properties`` on the +# openapi-generator-cli command line (see ``cmd`` below), which overrides +# the placeholder. As an additional safety net, ``make_version_dynamic`` +# (called after generation) rewrites the generator-emitted +# ``__version__`` line in the produced ``__init__.py`` to import from +# ``kinde_sdk._version``, so the placeholder never reaches a +# wrapper-generated artifact. This mirrors the management generator's +# treatment of ``openapitools.json`` for end-to-end symmetry. +config_content = f"""# openapi-generator-cli config for python frontend API +# NOTE: this file is regenerated by generate_frontend_sdk.py on every run. +# packageVersion holds the literal placeholder "{FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER}"; +# the resolved version is injected at openapi-generator-cli invocation time +# via --additional-properties. The file is intentionally NOT a source of +# truth for the SDK version - see kinde_sdk/_version.py. packageName: kinde_sdk.frontend projectName: kinde-python-sdk -packageVersion: 2.0.0 +packageVersion: {FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER} # It is recommended to use a released version of the generator. # For example: # generatorVersion: "7.13.0" """ - with open(CONFIG_FILE, 'w') as f: - f.write(config_content) - print(f"Created config file: {CONFIG_FILE}") +with open(CONFIG_FILE, 'w') as f: + f.write(config_content) +print( + f"Wrote config file: {CONFIG_FILE} " + f"(packageVersion={FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER!r} placeholder; " + f"resolved version {SDK_VERSION} supplied via CLI --additional-properties)" +) # Create temporary directory for generation with tempfile.TemporaryDirectory() as temp_dir: @@ -187,6 +233,10 @@ def restore_custom_files(backup_dir): "-g", "python", "-o", temp_dir, "-c", CONFIG_FILE, + # Override the YAML's "SDK_VERSION" placeholder with the resolved + # value from kinde_sdk/_version.py. The YAML is intentionally a + # template, not a source of truth. + f"--additional-properties=packageVersion={SDK_VERSION}", "--skip-validate-spec" ] @@ -232,7 +282,11 @@ def restore_custom_files(backup_dir): # Fix imports in the frontend directory fix_imports(OUTPUT_DIR) - + + # Replace the OpenAPI-emitted literal __version__ with an import from + # kinde_sdk._version, so the sub-package never drifts from the SDK. + make_version_dynamic(os.path.join(OUTPUT_DIR, "__init__.py")) + # Preserve custom imports preserve_custom_imports() diff --git a/generate_management_sdk.py b/generate_management_sdk.py index 813de223..9167211d 100755 --- a/generate_management_sdk.py +++ b/generate_management_sdk.py @@ -24,6 +24,13 @@ from pathlib import Path from typing import Dict, Any +from _sdk_generator_utils import ( + DYNAMIC_VERSION_IMPORT, + PACKAGE_VERSION_PLACEHOLDER, + SDK_VERSION, + make_version_dynamic, +) + # Configure logging logging.basicConfig( level=logging.INFO, @@ -32,6 +39,14 @@ logger = logging.getLogger(__name__) +# Re-export under the domain-specific name used in this script's log +# messages, docstrings, and the comments inside ``openapitools.json``. +# This is just an alias for ``_sdk_generator_utils.PACKAGE_VERSION_PLACEHOLDER`` +# - the shared constant remains the single source of truth, so the two +# generator scripts cannot drift on the placeholder value. +OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER = PACKAGE_VERSION_PLACEHOLDER + + # ============================================================================= # SDK CONFIGURATION # ============================================================================= @@ -297,8 +312,33 @@ def ensure_openapi_generator_ignore(config: Dict[str, Any]): def ensure_openapitools_config(config: Dict[str, Any]): """ - Ensure openapitools.json has the correct configuration for this SDK. - + Authoritatively (re)write ``openapitools.json`` so the committed file is + always self-evidently a *template*, never a second source of truth for + the SDK version. + + Design: the ``packageVersion`` field in the file is set to the literal + placeholder ``"SDK_VERSION"`` (the same name as the Python constant in + this script that holds the resolved value). Anyone reading the file + sees immediately that this is a placeholder, not a version literal. + + The resolved version is supplied at runtime via + ``--additional-properties=packageVersion=`` on the + ``openapi-generator-cli`` command line (see ``generate_sdk``), which + overrides the file's placeholder. As an additional safety net, + ``make_version_dynamic`` rewrites the generator-emitted + ``__version__ = "..."`` line in the produced ``__init__.py`` to import + from ``kinde_sdk._version``, so the placeholder never reaches a + wrapper-generated artifact. + + Direct ``openapi-generator-cli`` invocations that bypass the wrapper + (and therefore don't pass ``--additional-properties``) will produce a + ``configuration.py`` whose user-agent reads ``"OpenAPI-Generator/SDK_VERSION/python"``. + That is intentionally obvious-broken: it loudly signals "use the + wrapper script" rather than silently shipping a stale version. + + ``testv2/testv2_core/test_version_sync.py`` asserts this placeholder + invariant in CI so the file cannot regress to a literal version. + Args: config: SDK configuration dictionary """ @@ -307,7 +347,6 @@ def ensure_openapitools_config(config: Dict[str, Any]): config_file = Path(config["openapi_tools_config"]) generator_name = config["openapi_tools_generator_name"] - # Define the expected configuration expected_generator_config = { "generatorName": "python", "inputSpec": config["spec_url"], @@ -316,7 +355,9 @@ def ensure_openapitools_config(config: Dict[str, Any]): "additionalProperties": { "packageName": config["package_name"], "projectName": "kinde-python-sdk", - "packageVersion": "2.0.0", + # Self-documenting placeholder; resolved version is supplied at + # runtime via --additional-properties (see generate_sdk). + "packageVersion": OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER, "library": "urllib3", "generateSourceCodeOnly": True # CRITICAL: Only generate source code, not project templates }, @@ -324,10 +365,18 @@ def ensure_openapitools_config(config: Dict[str, Any]): "files": {} } - # Read existing config or create new one + previous_package_version = None if config_file.exists(): with open(config_file, 'r', encoding='utf-8') as f: openapitools_config = json.load(f) + previous_package_version = ( + openapitools_config + .get("generator-cli", {}) + .get("generators", {}) + .get(generator_name, {}) + .get("additionalProperties", {}) + .get("packageVersion") + ) else: openapitools_config = { "$schema": "https://raw.githubusercontent.com/OpenAPITools/openapi-generator-cli/master/apps/generator-cli/src/config.schema.json", @@ -339,7 +388,6 @@ def ensure_openapitools_config(config: Dict[str, Any]): } } - # Ensure generators section exists if "generator-cli" not in openapitools_config: openapitools_config["generator-cli"] = { "version": "7.9.0", @@ -350,14 +398,27 @@ def ensure_openapitools_config(config: Dict[str, Any]): if "generators" not in openapitools_config["generator-cli"]: openapitools_config["generator-cli"]["generators"] = {} - # Update or add the generator configuration openapitools_config["generator-cli"]["generators"][generator_name] = expected_generator_config - # Write back to file with open(config_file, 'w', encoding='utf-8') as f: json.dump(openapitools_config, f, indent=2) - print(f"✓ Updated {config_file} for {generator_name}") + if ( + previous_package_version + and previous_package_version != OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER + ): + print( + f"✓ Reset {config_file} packageVersion from {previous_package_version!r} " + f"back to the {OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER!r} placeholder. " + f"The resolved version ({SDK_VERSION}) is injected via " + f"--additional-properties at openapi-generator-cli invocation time." + ) + else: + print( + f"✓ Confirmed {config_file} packageVersion is the " + f"{OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER!r} placeholder " + f"(resolved version {SDK_VERSION} supplied via CLI --additional-properties)" + ) # ============================================================================= @@ -388,7 +449,7 @@ def generate_sdk(config: Dict[str, Any]) -> bool: additional_props = ",".join([ f"packageName={config['package_name']}", "projectName=kinde-python-sdk", - "packageVersion=2.0.0", + f"packageVersion={SDK_VERSION}", "library=urllib3", "generateSourceCodeOnly=true" ]) @@ -731,7 +792,11 @@ def generate_single_sdk(skip_tests: bool, no_diff: bool) -> bool: # Step 2: Fix imports fix_imports(config) - + + # Step 2b: Replace the OpenAPI-emitted literal __version__ with an import + # from kinde_sdk._version, so this sub-package never drifts from the SDK. + make_version_dynamic(Path(config["output_dir"]) / "__init__.py") + # Step 3: Add custom imports add_custom_imports(config) diff --git a/kinde_sdk/__init__.py b/kinde_sdk/__init__.py index 5e968cc8..1283d617 100644 --- a/kinde_sdk/__init__.py +++ b/kinde_sdk/__init__.py @@ -25,7 +25,7 @@ from kinde_sdk.core.framework.null_framework import NullFramework from kinde_sdk.core.session_management import KindeSessionManagement -__version__ = "2.2.0" +from kinde_sdk._version import __version__ # noqa: F401 (re-exported) __all__ = [ "OAuth", diff --git a/kinde_sdk/_version.py b/kinde_sdk/_version.py new file mode 100644 index 00000000..77694e4f --- /dev/null +++ b/kinde_sdk/_version.py @@ -0,0 +1,36 @@ +"""Single source of truth for the Kinde Python SDK version. + +This module is intentionally minimal: it must not import anything else, +so it can be safely imported from any sub-package's ``__init__`` without +risking a circular import with ``kinde_sdk/__init__.py``. + +Derived surfaces (all kept in sync from this string): + +* ``kinde_sdk.__version__`` re-exports it directly. +* The generated ``kinde_sdk/management/__init__.py`` and + ``kinde_sdk/frontend/__init__.py`` re-export it via a post-generation + rewrite, so all three namespaces report the same version. +* ``pyproject.toml`` resolves its dynamic version from this attribute, + so distribution metadata (and the ``SDKTracker`` User-Agent) matches. +* Both OpenAPI generator configs - ``openapitools.json`` (management, + tracked in git) and ``generator/frontend_config.yaml`` (frontend, + transient and rewritten on each generator run) - intentionally store + the literal placeholder ``"SDK_VERSION"`` in their ``packageVersion`` + field rather than a version literal, so neither file can be mistaken + for a second source of truth. The resolved value is injected at + runtime by the respective wrapper script via + ``--additional-properties=packageVersion=`` on the + ``openapi-generator-cli`` command line, and the post-generation + ``make_version_dynamic`` rewrite imports ``__version__`` from this + module so the placeholder never reaches a wrapper-generated artifact. + ``testv2/testv2_core/test_version_sync.py`` enforces both placeholder + invariants in CI. + +Bump this string on every release; nothing else needs touching by hand. +After bumping, run both generator scripts (or rely on a release pipeline +that does) so any regenerated sub-package files are committed alongside. +The generator-config files themselves are unaffected by the bump - they +permanently hold the ``"SDK_VERSION"`` placeholder. +""" + +__version__ = "2.3.0" diff --git a/kinde_sdk/frontend/__init__.py b/kinde_sdk/frontend/__init__.py index 4399d137..90966905 100644 --- a/kinde_sdk/frontend/__init__.py +++ b/kinde_sdk/frontend/__init__.py @@ -15,7 +15,7 @@ """ # noqa: E501 -__version__ = "2.0.0" +from kinde_sdk._version import __version__ # single source of truth; see kinde_sdk/_version.py # import apis into sdk package from kinde_sdk.frontend.api.billing_api import BillingApi diff --git a/kinde_sdk/management/__init__.py b/kinde_sdk/management/__init__.py index 477a190c..76e6264e 100644 --- a/kinde_sdk/management/__init__.py +++ b/kinde_sdk/management/__init__.py @@ -15,7 +15,7 @@ """ # noqa: E501 -__version__ = "2.0.0" +from kinde_sdk._version import __version__ # single source of truth; see kinde_sdk/_version.py # Define package exports __all__ = [ diff --git a/openapitools.json b/openapitools.json index d85cc8ac..0595bfde 100644 --- a/openapitools.json +++ b/openapitools.json @@ -13,7 +13,7 @@ "additionalProperties": { "packageName": "kinde_sdk.management", "projectName": "kinde-python-sdk", - "packageVersion": "2.0.0", + "packageVersion": "SDK_VERSION", "library": "urllib3", "generateSourceCodeOnly": true }, diff --git a/pyproject.toml b/pyproject.toml index cf2f04e7..53226a15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,19 @@ [project] name = "kinde-python-sdk" -version = "2.2.0" +# Version is derived from kinde_sdk/_version.py so there is exactly one +# source of truth for the SDK version. Bump that file on every release; +# pyproject.toml, kinde_sdk.__version__, kinde_sdk.frontend.__version__ +# and kinde_sdk.management.__version__ all follow automatically. +# +# The OpenAPI generator configs are deliberately *not* second sources of +# truth: both openapitools.json (management) and +# generator/frontend_config.yaml (frontend, transient) store the literal +# placeholder "SDK_VERSION" in packageVersion. The resolved version is +# injected at runtime via --additional-properties on the +# openapi-generator-cli command line by the wrapper scripts. +# testv2/testv2_core/test_version_sync.py enforces both placeholder +# invariants in CI, so bumping _version.py here is genuinely sufficient. +dynamic = ["version"] authors = [ { name = "Kinde Engineering", email = "engineering@kinde.com" }, ] @@ -78,6 +91,9 @@ package-dir = {"" = "."} include-package-data = true zip-safe = false +[tool.setuptools.dynamic] +version = {attr = "kinde_sdk._version.__version__"} + [tool.setuptools.package-data] "kinde_sdk" = ["**/*.py", "**/*.json", "**/*.yaml", "**/*.yml"] "kinde_fastapi" = ["**/*.py", "**/*.json", "**/*.yaml", "**/*.yml"] diff --git a/testv2/testv2_core/test_version_sync.py b/testv2/testv2_core/test_version_sync.py new file mode 100644 index 00000000..6d2130ec --- /dev/null +++ b/testv2/testv2_core/test_version_sync.py @@ -0,0 +1,263 @@ +"""Regression tests guarding the single source of truth for the SDK version. + +``kinde_sdk/_version.py`` is the only place the SDK version should be +edited (see PR #182). Everything else derives from it: + +* the top-level ``kinde_sdk`` package re-exports it; +* the generated ``kinde_sdk.frontend`` and ``kinde_sdk.management`` + sub-packages re-export it via a post-generation rewrite; +* the distribution metadata produced from ``pyproject.toml`` resolves + from it dynamically. + +The two OpenAPI generator configs (``openapitools.json`` for management, +``generator/frontend_config.yaml`` for frontend) use a different +discipline: they both store the literal self-documenting placeholder +``"SDK_VERSION"`` in their ``packageVersion`` field, and the resolved +value is injected at runtime via +``--additional-properties=packageVersion=`` on the +``openapi-generator-cli`` command line. This makes it visually +impossible for either file to be mistaken for a second source of truth, +and the tests below assert the placeholder invariant. + +These tests fail loudly the moment any of those surfaces gets out of +sync (or a placeholder regresses to a literal), so a future release +can't silently reintroduce the drift PR #182 was written to eliminate. +""" + +import json +import re +from importlib.metadata import PackageNotFoundError, version as dist_version +from pathlib import Path + +import pytest + +import kinde_sdk +import kinde_sdk.frontend +import kinde_sdk.management +from kinde_sdk._version import __version__ as sot_version + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_top_level_package_version_matches_source_of_truth(): + assert kinde_sdk.__version__ == sot_version + + +def test_frontend_subpackage_version_matches_source_of_truth(): + assert kinde_sdk.frontend.__version__ == sot_version + + +def test_management_subpackage_version_matches_source_of_truth(): + assert kinde_sdk.management.__version__ == sot_version + + +#: The single attr path that the ``[project].version`` field is allowed to +#: resolve from. Both branches of +#: ``test_distribution_metadata_matches_source_of_truth`` enforce this +#: contract: when installed, indirectly via the resolved dist metadata; +#: when not installed, directly via the pyproject.toml wiring. +_EXPECTED_DYNAMIC_VERSION_ATTR = "kinde_sdk._version.__version__" + +#: ``[tool.setuptools.dynamic]`` inline-table form: +#: ``version = {attr = "kinde_sdk._version.__version__"}``. +_DYNAMIC_VERSION_ATTR_RE = re.compile( + r'^\s*version\s*=\s*\{\s*attr\s*=\s*["\']([^"\']+)["\']\s*\}\s*$', + re.MULTILINE, +) + +#: Bare literal form: ``version = "x.y.z"`` on its own line. Used to +#: detect the failure mode of someone removing ``dynamic = ["version"]`` +#: and hard-coding a literal that would bypass ``_version.py`` entirely. +_LITERAL_VERSION_RE = re.compile( + r'^\s*version\s*=\s*["\']([^"\']+)["\']\s*$', + re.MULTILINE, +) + + +def _assert_pyproject_dynamic_version_wired_to_ssot(): + """Verify pyproject.toml is wired such that the dist version derives from the SSoT. + + Used as the raw-checkout fallback path of + ``test_distribution_metadata_matches_source_of_truth``. There is no + literal version string to "extract" from pyproject.toml: the + ``[project]`` table declares ``dynamic = ["version"]`` and the + actual value is resolved at build time by setuptools from the attr + declared in ``[tool.setuptools.dynamic]``. The strongest assertion + we can make without a built/installed package is that this wiring + is intact and still points at ``kinde_sdk._version.__version__`` - + which is the contract that guarantees the dist version equals + ``sot_version`` on every install. Drift in the wiring (literal + version added, attr path changed, declaration removed) is detected + here before the next build silently ships a stale value. + """ + pyproject_path = REPO_ROOT / "pyproject.toml" + assert pyproject_path.exists(), ( + f"Expected {pyproject_path} to exist; it is the source of the dist " + "version wiring." + ) + pyproject = pyproject_path.read_text(encoding="utf-8") + + literal_m = _LITERAL_VERSION_RE.search(pyproject) + assert literal_m is None, ( + f"pyproject.toml contains a literal `version = {literal_m.group(1)!r}` " + f"that would bypass kinde_sdk/_version.py (current SSoT: {sot_version!r}). " + "The dist version must be declared `dynamic = [\"version\"]` with " + "`[tool.setuptools.dynamic] version = {attr = " + f"\"{_EXPECTED_DYNAMIC_VERSION_ATTR}\"}}` so it derives from the SSoT." + ) + + attr_m = _DYNAMIC_VERSION_ATTR_RE.search(pyproject) + assert attr_m is not None, ( + "pyproject.toml is missing the expected " + "`[tool.setuptools.dynamic] version = {attr = \"...\"}` declaration. " + "Without it the dist version cannot derive from kinde_sdk/_version.py " + "and would silently drift on the next install." + ) + assert attr_m.group(1) == _EXPECTED_DYNAMIC_VERSION_ATTR, ( + f"pyproject.toml's [tool.setuptools.dynamic] version.attr points at " + f"{attr_m.group(1)!r}, expected {_EXPECTED_DYNAMIC_VERSION_ATTR!r}. " + "The dist version no longer derives from the SSoT - any install would " + f"report whatever {attr_m.group(1)!r} resolves to, not " + f"kinde_sdk._version.__version__ ({sot_version!r})." + ) + + +def test_distribution_metadata_matches_source_of_truth(): + """pyproject.toml resolves its version dynamically from ``kinde_sdk._version``. + + When the package is installed (editable or otherwise) the installed + dist metadata - which is what ``SDKTracker`` reads to build the + User-Agent header sent to Kinde - must match the source-of-truth + string. In raw checkouts (no ``pip install -e .`` yet) there is no + dist metadata to compare against, so we fall back to asserting the + *wiring* in pyproject.toml: the ``[tool.setuptools.dynamic]`` + declaration must still point at ``kinde_sdk._version.__version__``, + which is the contract that guarantees the dist version derives from + the SSoT on the next install. Either branch catches drift. + + We intentionally do NOT try to "extract a version literal" from + pyproject.toml in the fallback: the ``[project]`` table declares + ``dynamic = ["version"]`` so there is no literal to extract. The + wiring check is the strongest assertion possible without building + the distribution. + """ + try: + installed = dist_version("kinde-python-sdk") + except PackageNotFoundError: + _assert_pyproject_dynamic_version_wired_to_ssot() + return + + assert installed == sot_version + + +OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER = "SDK_VERSION" +FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER = "SDK_VERSION" + + +def test_openapitools_json_package_version_is_self_documenting_placeholder(): + """``openapitools.json``'s ``packageVersion`` must be the placeholder, not a literal. + + The committed ``openapitools.json`` is deliberately *not* a version + literal - it stores the self-documenting placeholder + ``"SDK_VERSION"`` (same name as the Python constant in + ``generate_management_sdk.py`` that holds the resolved value). This + makes it impossible to mistake the file for a second source of truth. + + The resolved version is injected at runtime via + ``--additional-properties=packageVersion=`` on the + ``openapi-generator-cli`` command line, which overrides the file's + placeholder. As an additional safety net, + ``generate_management_sdk.py::make_version_dynamic`` rewrites the + generator-emitted ``__version__`` line in the produced ``__init__.py`` + to import from ``kinde_sdk._version``, so the placeholder never + reaches a wrapper-generated artifact. + + This test fails if anyone (a) reintroduces a version literal into the + file or (b) replaces the placeholder with a different sentinel + without also updating ``ensure_openapitools_config``. + """ + config_path = REPO_ROOT / "openapitools.json" + assert config_path.exists(), ( + f"Expected {config_path} to exist; the management generator config " + "is part of the version SSoT chain." + ) + + with config_path.open(encoding="utf-8") as f: + openapitools_config = json.load(f) + + try: + package_version = ( + openapitools_config["generator-cli"]["generators"]["management"] + ["additionalProperties"]["packageVersion"] + ) + except KeyError as exc: + pytest.fail( + f"openapitools.json is missing the expected " + f"generator-cli.generators.management.additionalProperties.packageVersion " + f"path: {exc}. Did the file's schema change? Update " + "generate_management_sdk.py::ensure_openapitools_config and this test." + ) + + assert package_version == OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER, ( + f"openapitools.json packageVersion is {package_version!r}, expected " + f"the self-documenting placeholder " + f"{OPENAPITOOLS_PACKAGE_VERSION_PLACEHOLDER!r}. The committed file must " + "not hold a version literal - that would make it a second source of " + "truth that could drift from kinde_sdk/_version.py. Run " + "`python3 generate_management_sdk.py` to reset the placeholder. The " + f"resolved version ({sot_version}) is supplied at runtime via " + "--additional-properties on the openapi-generator-cli command line; see " + "generate_management_sdk.py::ensure_openapitools_config." + ) + + +def test_frontend_generator_config_package_version_is_self_documenting_placeholder(): + """``generator/frontend_config.yaml``'s ``packageVersion`` must be the placeholder. + + ``generate_frontend_sdk.py`` rewrites this file from a template literal + on every run, holding the self-documenting placeholder + ``"SDK_VERSION"`` (same string as the Python constant + ``FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER`` in that script). The + resolved version is injected at runtime via + ``--additional-properties=packageVersion=`` on the + ``openapi-generator-cli`` command line, which overrides the YAML + placeholder. As an additional safety net, ``make_version_dynamic`` + rewrites the generator-emitted ``__version__`` line in the produced + ``__init__.py`` to import from ``kinde_sdk._version`` so the + placeholder never reaches a wrapper-generated artifact. + + The YAML file is not tracked in git; it's a transient artifact written + by the wrapper. When the file is present in the checkout (i.e. the + script has been run) this test asserts the placeholder invariant. If + the file is absent, the test skips. This mirrors the management + generator's ``openapitools.json`` treatment for end-to-end symmetry. + """ + config_path = REPO_ROOT / "generator" / "frontend_config.yaml" + if not config_path.exists(): + pytest.skip( + "generator/frontend_config.yaml has not been written yet " + "(generate_frontend_sdk.py has not been run in this checkout)" + ) + + package_version = None + for line in config_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("packageVersion:"): + package_version = stripped.split(":", 1)[1].strip().strip('"').strip("'") + break + + assert package_version is not None, ( + f"Could not find a `packageVersion:` line in {config_path}; " + "did generate_frontend_sdk.py's template change?" + ) + assert package_version == FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER, ( + f"{config_path} packageVersion is {package_version!r}, expected " + f"the self-documenting placeholder " + f"{FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER!r}. The YAML must not " + "hold a version literal - that would make it a second source of truth " + f"that could drift from kinde_sdk/_version.py. Run " + "`python3 generate_frontend_sdk.py` to reset the placeholder. The " + f"resolved version ({sot_version}) is supplied at runtime via " + "--additional-properties on the openapi-generator-cli command line; " + "see generate_frontend_sdk.py." + )