Skip to content
Open
17 changes: 16 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -61,4 +70,10 @@ docs/_build/
.ipynb_checkpoints

.idea/
.vscode/
.vscode/

# Local environment files (secrets, never commit)
.env
.env.*
!.env.example
!.env.sample
154 changes: 154 additions & 0 deletions _sdk_generator_utils.py
Original file line number Diff line number Diff line change
@@ -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=<SDK_VERSION>``
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=<SDK_VERSION>`` 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",
]
70 changes: 62 additions & 8 deletions generate_frontend_sdk.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
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"
OUTPUT_DIR = "kinde_sdk/frontend"
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}")
Expand Down Expand Up @@ -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:
Expand All @@ -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"
]

Expand Down Expand Up @@ -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()

Expand Down
Loading