Skip to content

Commit baa9998

Browse files
authored
Merge pull request #182 from kinde-oss/fix/sub-package-version-drift
fix: derive sub-package __version__ from a single source of truth
2 parents 44d13fb + 882d539 commit baa9998

11 files changed

Lines changed: 628 additions & 25 deletions

File tree

.gitignore

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ var/
2424
.installed.cfg
2525
*.egg
2626

27+
# Poetry is not the supported install path for this project (build-backend
28+
# is setuptools; CI uses `pip install -r requirements.txt`). A committed
29+
# poetry.lock creates a two-tier dependency-resolution story - pip users
30+
# get version ranges from pyproject.toml/requirements.txt while Poetry
31+
# users get pinned versions from the lock file - which can drift. If
32+
# Poetry adoption is ever formalised, that migration belongs in a
33+
# dedicated PR (with [tool.poetry] sections and CI updates).
34+
poetry.lock
35+
2736
# PyInstaller
2837
# Usually these files are written by a python script from a template
2938
# before PyInstaller builds the exe, so as to inject date/other infos into it.
@@ -61,4 +70,10 @@ docs/_build/
6170
.ipynb_checkpoints
6271

6372
.idea/
64-
.vscode/
73+
.vscode/
74+
75+
# Local environment files (secrets, never commit)
76+
.env
77+
.env.*
78+
!.env.example
79+
!.env.sample

_sdk_generator_utils.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Shared helpers for the OpenAPI generator wrapper scripts.
2+
3+
Both ``generate_management_sdk.py`` and ``generate_frontend_sdk.py`` need
4+
to do the same handful of version-related operations: resolve the SDK
5+
version from ``kinde_sdk/_version.py``, know the
6+
``"SDK_VERSION"`` placeholder used in the generator config files, and
7+
post-process the OpenAPI-emitted ``__init__.py`` so its ``__version__``
8+
is imported from ``kinde_sdk._version`` rather than baked in as a
9+
literal. Keeping a single implementation of each here eliminates the
10+
slow drift that was starting to appear between the two scripts (e.g.
11+
diverging type annotations, log message styling, and error handling).
12+
13+
The module is intentionally named with a leading underscore to signal
14+
that it is internal to this repository's generator tooling; it is not
15+
part of the public ``kinde_sdk`` package and is not shipped as part of
16+
the distribution.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import re
22+
from pathlib import Path
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Version resolution
27+
# ---------------------------------------------------------------------------
28+
29+
# Path to the single source of truth for the SDK version. Both generator
30+
# scripts sit at the repo root next to ``kinde_sdk/``, and so does this
31+
# module - so ``Path(__file__).resolve().parent`` is the repo root.
32+
_VERSION_FILE = Path(__file__).resolve().parent / "kinde_sdk" / "_version.py"
33+
34+
35+
def read_sdk_version() -> str:
36+
"""Read the canonical SDK version string from ``kinde_sdk/_version.py``.
37+
38+
The resolved value is supplied to ``openapi-generator-cli`` at
39+
runtime via ``--additional-properties=packageVersion=<SDK_VERSION>``
40+
so that artifacts which embed the version literally (e.g. user-agent
41+
headers in the generated ``configuration.py``) stay in lockstep with
42+
the SDK. The generator config files themselves
43+
(``openapitools.json`` and ``generator/frontend_config.yaml``)
44+
hold the literal placeholder :data:`PACKAGE_VERSION_PLACEHOLDER` so
45+
they cannot become second sources of truth; see
46+
``testv2/testv2_core/test_version_sync.py`` for the enforcing tests.
47+
48+
Raises:
49+
RuntimeError: if ``_version.py`` exists but no ``__version__``
50+
assignment can be parsed out of it.
51+
"""
52+
text = _VERSION_FILE.read_text(encoding="utf-8")
53+
match = re.search(
54+
r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE
55+
)
56+
if not match:
57+
raise RuntimeError(
58+
f"Could not parse __version__ from {_VERSION_FILE}; "
59+
"the generator can't derive packageVersion."
60+
)
61+
return match.group(1)
62+
63+
64+
#: Resolved SDK version, ready for both generator scripts to use.
65+
SDK_VERSION: str = read_sdk_version()
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# Generator-config placeholder
70+
# ---------------------------------------------------------------------------
71+
72+
#: Literal string written into the ``packageVersion`` field of both
73+
#: generator config files (``openapitools.json`` and
74+
#: ``generator/frontend_config.yaml``). The committed/written value is
75+
#: deliberately *not* a version literal - it is this self-documenting
76+
#: placeholder, so the files cannot be mistaken for a second source of
77+
#: truth. The resolved version is injected at runtime via
78+
#: ``--additional-properties=packageVersion=<SDK_VERSION>`` on the
79+
#: ``openapi-generator-cli`` command line, which overrides whatever the
80+
#: file contains. The placeholder invariant is asserted in CI by
81+
#: ``testv2/testv2_core/test_version_sync.py``.
82+
PACKAGE_VERSION_PLACEHOLDER: str = "SDK_VERSION"
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Post-generation ``__version__`` rewrite
87+
# ---------------------------------------------------------------------------
88+
89+
#: Line written into the generated sub-package ``__init__.py`` in place
90+
#: of the OpenAPI-emitted ``__version__ = "..."`` literal. Holding a
91+
#: single constant here means both generator scripts (and any future
92+
#: ones) emit byte-identical replacements.
93+
DYNAMIC_VERSION_IMPORT: str = (
94+
"from kinde_sdk._version import __version__ "
95+
"# single source of truth; see kinde_sdk/_version.py"
96+
)
97+
98+
99+
_LITERAL_VERSION_LINE = re.compile(
100+
r'^__version__\s*=\s*["\'][^"\']+["\']\s*$',
101+
re.MULTILINE,
102+
)
103+
104+
105+
def make_version_dynamic(init_file: Path) -> None:
106+
"""Rewrite the ``__version__`` literal in a generated ``__init__.py``.
107+
108+
Replaces the OpenAPI-emitted ``__version__ = "X"`` line with
109+
:data:`DYNAMIC_VERSION_IMPORT` so the sub-package re-exports the
110+
canonical value from ``kinde_sdk._version`` and can never drift from
111+
the SDK source of truth.
112+
113+
Idempotent: a no-op if the file already imports ``__version__`` from
114+
``kinde_sdk._version``. Tolerant of missing files and missing
115+
``__version__`` lines - logs a warning and returns rather than
116+
raising, because the calling scripts treat this as a best-effort
117+
post-generation step.
118+
119+
Args:
120+
init_file: Path to the generated sub-package ``__init__.py``.
121+
Always passed as a :class:`pathlib.Path` (callers convert
122+
string paths via :func:`pathlib.Path` at the call site).
123+
"""
124+
if not init_file.exists():
125+
print(f"⚠️ Cannot rewrite __version__: {init_file} does not exist")
126+
return
127+
128+
text = init_file.read_text(encoding="utf-8")
129+
130+
if "from kinde_sdk._version import __version__" in text:
131+
print(f"✓ {init_file} already imports __version__ from kinde_sdk._version")
132+
return
133+
134+
new_text, n = _LITERAL_VERSION_LINE.subn(
135+
DYNAMIC_VERSION_IMPORT, text, count=1
136+
)
137+
if n == 0:
138+
print(
139+
f"⚠️ Could not find a literal __version__ in {init_file} to replace; "
140+
"is the OpenAPI generator template still emitting one?"
141+
)
142+
return
143+
144+
init_file.write_text(new_text, encoding="utf-8")
145+
print(f"✓ Rewrote __version__ in {init_file} to import from kinde_sdk._version")
146+
147+
148+
__all__ = [
149+
"DYNAMIC_VERSION_IMPORT",
150+
"PACKAGE_VERSION_PLACEHOLDER",
151+
"SDK_VERSION",
152+
"make_version_dynamic",
153+
"read_sdk_version",
154+
]

generate_frontend_sdk.py

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,44 @@
11
import os
2+
import re
23
import subprocess
34
import sys
45
import shutil
56
import tempfile
7+
from pathlib import Path
8+
9+
from _sdk_generator_utils import (
10+
PACKAGE_VERSION_PLACEHOLDER,
11+
SDK_VERSION,
12+
make_version_dynamic as _make_version_dynamic,
13+
)
614

715
# OpenAPI spec URL from Kinde Frontend API
816
OPENAPI_SPEC_URL = "https://api-spec.kinde.com/kinde-frontend-api-spec.yaml"
917
OUTPUT_DIR = "kinde_sdk/frontend"
1018
GENERATOR_DIR = "generator"
1119
CONFIG_FILE = f"{GENERATOR_DIR}/frontend_config.yaml"
1220

21+
22+
# Re-export under the domain-specific name used in this script's log
23+
# messages, docstrings, and the comments embedded in
24+
# ``generator/frontend_config.yaml``. This is just an alias for
25+
# ``_sdk_generator_utils.PACKAGE_VERSION_PLACEHOLDER`` - the shared
26+
# constant remains the single source of truth, so the two generator
27+
# scripts cannot drift on the placeholder value.
28+
FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER = PACKAGE_VERSION_PLACEHOLDER
29+
30+
31+
def make_version_dynamic(init_file) -> None:
32+
"""Thin wrapper that accepts ``str`` paths and forwards to the shared util.
33+
34+
The shared implementation in ``_sdk_generator_utils`` is typed
35+
``init_file: Path``, but this script historically passed
36+
``os.path.join(...)`` strings; converting at this single call-site
37+
keeps the public signature stable while still routing all rewrite
38+
logic through one canonical implementation.
39+
"""
40+
_make_version_dynamic(Path(init_file))
41+
1342
def fix_imports(directory):
1443
"""Fix import paths in generated Python files."""
1544
print(f"Fixing imports in {directory}")
@@ -162,20 +191,37 @@ def restore_custom_files(backup_dir):
162191
os.makedirs(GENERATOR_DIR)
163192
print(f"Created directory: {GENERATOR_DIR}")
164193

165-
# Create frontend config.yaml if it doesn't exist
166-
if not os.path.isfile(CONFIG_FILE):
167-
config_content = """# openapi-generator-cli config for python frontend API
194+
# Always (re)write the frontend config so its ``packageVersion`` field
195+
# stays the self-documenting ``SDK_VERSION`` placeholder. The resolved
196+
# version is supplied at runtime via ``--additional-properties`` on the
197+
# openapi-generator-cli command line (see ``cmd`` below), which overrides
198+
# the placeholder. As an additional safety net, ``make_version_dynamic``
199+
# (called after generation) rewrites the generator-emitted
200+
# ``__version__`` line in the produced ``__init__.py`` to import from
201+
# ``kinde_sdk._version``, so the placeholder never reaches a
202+
# wrapper-generated artifact. This mirrors the management generator's
203+
# treatment of ``openapitools.json`` for end-to-end symmetry.
204+
config_content = f"""# openapi-generator-cli config for python frontend API
205+
# NOTE: this file is regenerated by generate_frontend_sdk.py on every run.
206+
# packageVersion holds the literal placeholder "{FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER}";
207+
# the resolved version is injected at openapi-generator-cli invocation time
208+
# via --additional-properties. The file is intentionally NOT a source of
209+
# truth for the SDK version - see kinde_sdk/_version.py.
168210
169211
packageName: kinde_sdk.frontend
170212
projectName: kinde-python-sdk
171-
packageVersion: 2.0.0
213+
packageVersion: {FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER}
172214
# It is recommended to use a released version of the generator.
173215
# For example:
174216
# generatorVersion: "7.13.0"
175217
"""
176-
with open(CONFIG_FILE, 'w') as f:
177-
f.write(config_content)
178-
print(f"Created config file: {CONFIG_FILE}")
218+
with open(CONFIG_FILE, 'w') as f:
219+
f.write(config_content)
220+
print(
221+
f"Wrote config file: {CONFIG_FILE} "
222+
f"(packageVersion={FRONTEND_CONFIG_PACKAGE_VERSION_PLACEHOLDER!r} placeholder; "
223+
f"resolved version {SDK_VERSION} supplied via CLI --additional-properties)"
224+
)
179225

180226
# Create temporary directory for generation
181227
with tempfile.TemporaryDirectory() as temp_dir:
@@ -187,6 +233,10 @@ def restore_custom_files(backup_dir):
187233
"-g", "python",
188234
"-o", temp_dir,
189235
"-c", CONFIG_FILE,
236+
# Override the YAML's "SDK_VERSION" placeholder with the resolved
237+
# value from kinde_sdk/_version.py. The YAML is intentionally a
238+
# template, not a source of truth.
239+
f"--additional-properties=packageVersion={SDK_VERSION}",
190240
"--skip-validate-spec"
191241
]
192242

@@ -232,7 +282,11 @@ def restore_custom_files(backup_dir):
232282

233283
# Fix imports in the frontend directory
234284
fix_imports(OUTPUT_DIR)
235-
285+
286+
# Replace the OpenAPI-emitted literal __version__ with an import from
287+
# kinde_sdk._version, so the sub-package never drifts from the SDK.
288+
make_version_dynamic(os.path.join(OUTPUT_DIR, "__init__.py"))
289+
236290
# Preserve custom imports
237291
preserve_custom_imports()
238292

0 commit comments

Comments
 (0)