Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/regenerate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,86 @@ jobs:
- 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
Expand Down
1 change: 1 addition & 0 deletions .openapi-generator-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
git_push.sh
README.md
setup.py
hotdata/_auth.py
44 changes: 40 additions & 4 deletions .openapi-generator-templates/configuration.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,12 @@ conf = {{packageName}}.Configuration(
self.temp_folder_path = None
"""Temp file folder for downloading files
"""
self.api_key = api_key
# Transparent API-token -> JWT exchange. `api_key` is a property whose
# getter returns a live JWT minted from this credential (see _auth.py);
# the manager is created eagerly here (never lazily in the getter) so
# concurrent first requests don't each build one. The setter rebuilds it.
from {{packageName}}._auth import _TokenManager
self._token_manager = _TokenManager(api_key, self) if api_key is not None else None
"""Hotdata API key, sent as `Authorization: Bearer <key>`."""
# apiKey-security values (X-Workspace-Id, X-Session-Id), keyed by
# scheme name. Read by the generated `auth_settings()` below.
Expand Down Expand Up @@ -451,13 +456,20 @@ conf = {{packageName}}.Configuration(
result = cls.__new__(cls)
memo[id(self)] = result
for k, v in self.__dict__.items():
if k not in ('logger', 'logger_file_handler'):
# _token_manager holds a threading.Lock and a urllib3 PoolManager,
# neither of which is deepcopy-able; rebuild it below from the
# (deepcopy-safe) credential string instead.
if k not in ('logger', 'logger_file_handler', '_token_manager'):
setattr(result, k, copy.deepcopy(v, memo))
# shallow copy of loggers
result.logger = copy.copy(self.logger)
# use setters to configure loggers
result.logger_file = self.logger_file
result.debug = self.debug
# rebuild the token manager bound to the copy (never deepcopy lock/pool)
from {{packageName}}._auth import _TokenManager
tm = self._token_manager
result._token_manager = _TokenManager(tm._credential, result) if tm else None
return result

def __setattr__(self, name: str, value: Any) -> None:
Expand Down Expand Up @@ -608,6 +620,26 @@ conf = {{packageName}}.Configuration(

return None

@property
def api_key(self) -> Optional[str]:
"""Live bearer credential, sent as `Authorization: Bearer <value>`.

Backed by the regeneration-immune `_TokenManager` (see `{{packageName}}._auth`):
an opaque API token is transparently exchanged for a short-lived JWT and
kept fresh, while a credential already shaped like a JWT (or exchange
opted out) is returned unchanged. `auth_settings()` reads this on every
request, so the wire always carries a current token.
"""
# Read the manager once: a concurrent `api_key` reset could otherwise
# set it to None between the check and the `.bearer_value()` call.
tm = self._token_manager
return None if tm is None else tm.bearer_value()

@api_key.setter
def api_key(self, value: Optional[str]) -> None:
from {{packageName}}._auth import _TokenManager
self._token_manager = _TokenManager(value, self) if value is not None else None

@property
def workspace_id(self) -> Optional[str]:
"""Public id of the target workspace (sent as `X-Workspace-Id`)."""
Expand Down Expand Up @@ -689,15 +721,19 @@ conf = {{packageName}}.Configuration(
}
{{/isBasicBasic}}
{{#isBasicBearer}}
if self.api_key is not None:
# Resolve the bearer token once: `api_key` is a property that may mint a
# JWT and take the token-manager lock, so a second read would lock twice
# and could race a concurrent `api_key` reset (yielding `Bearer None`).
{{name}}_token = self.api_key
if {{name}}_token is not None:
auth['{{name}}'] = {
'type': 'bearer',
'in': 'header',
{{#bearerFormat}}
'format': '{{.}}',
{{/bearerFormat}}
'key': 'Authorization',
'value': 'Bearer ' + self.api_key
'value': 'Bearer ' + {{name}}_token
}
{{/isBasicBearer}}
{{#isHttpSignature}}
Expand Down
Loading
Loading