diff --git a/.bumpy/python-codegen-hover-docs.md b/.bumpy/python-codegen-hover-docs.md
new file mode 100644
index 000000000..61abb58cf
--- /dev/null
+++ b/.bumpy/python-codegen-hover-docs.md
@@ -0,0 +1,5 @@
+---
+varlock: minor
+---
+
+Python codegen: `@generatePythonEnv` now emits a lazily loaded `ENV` object with a documented property per item, so editors show item descriptions, `@docs()` links and deprecations on hover where values are read. `Env` TypedDict fields get docstrings instead of `#` comments.
diff --git a/packages/varlock-website/src/content/docs/guides/code-generation.mdx b/packages/varlock-website/src/content/docs/guides/code-generation.mdx
index c61299e44..ee1fe6ba1 100644
--- a/packages/varlock-website/src/content/docs/guides/code-generation.mdx
+++ b/packages/varlock-website/src/content/docs/guides/code-generation.mdx
@@ -54,7 +54,7 @@ Varlock ships generators for several languages. Each emits a **self-contained, i
| Language | Decorator | Loader | Guide |
| --- | --- | --- | --- |
-| Python | `@generatePythonEnv` | `load_env()` | [Python](/integrations/python/) |
+| Python | `@generatePythonEnv` | `ENV` / `load_env()` | [Python](/integrations/python/) |
| Rust | `@generateRustEnv` | `env::load()` | [Rust](/integrations/rust/) |
| Go | `@generateGoEnv` | `env.Load()` | [Go](/integrations/go/) |
| PHP | `@generatePhpEnv` | `Env::load()` | [PHP](/integrations/php/) |
diff --git a/packages/varlock-website/src/content/docs/integrations/python.mdx b/packages/varlock-website/src/content/docs/integrations/python.mdx
index ef29d18bb..0cffdeb7f 100644
--- a/packages/varlock-website/src/content/docs/integrations/python.mdx
+++ b/packages/varlock-website/src/content/docs/integrations/python.mdx
@@ -5,7 +5,7 @@ description: Use varlock with Python via a generated typed env module, Pydantic
To use varlock with Python, [install the standalone binary](/getting-started/installation/#as-a-standalone-binary) and run your app under [`varlock run`](/reference/cli/load-and-run/#run). It loads and validates your env, then injects it into the process.
-Add [`@generatePythonEnv`](/reference/root-decorators/#generatepythonenv) to your schema to generate a small, self-contained module: a coerced `Env` [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict), a `load_env()` function that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant.
+Add [`@generatePythonEnv`](/reference/root-decorators/#generatepythonenv) to your schema to generate a small, self-contained module: a lazily loaded `ENV` object with typed attributes, a coerced `Env` [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) with a `load_env()` function that parses the injected [`__VARLOCK_ENV`](/reference/reserved-variables/#__varlock_env) blob, and a `SENSITIVE_KEYS` constant.
```env-spec title=".env.schema"
# @generatePythonEnv(path=env.py)
@@ -15,43 +15,47 @@ The file is regenerated automatically on [`varlock load`](/reference/cli/load-an
## Reading values
-Call `load_env()` once, then read keys off the dict. Values are coerced (`int`/`bool`/etc.), not raw strings:
+Import `ENV` and read keys as attributes. Values are coerced (`int`/`bool`/etc.), not raw strings:
```python
-from env import load_env, SENSITIVE_KEYS
-
-env = load_env() # call once, hold or pass it around
-port = env["DB_PORT"] # int
-debug = env["DEBUG"] # bool
+from env import ENV
-# build your own redaction using SENSITIVE_KEYS
-safe = {k: ("***" if k in SENSITIVE_KEYS else v) for k, v in env.items()}
+port = ENV.DB_PORT # int | None (optional item)
+debug = ENV.DEBUG # bool
```
```bash
varlock run -- python main.py
```
-`load_env()` raises a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`). Optional keys that are unset are absent from the dict (`NotRequired`), not present as `None`.
+`ENV` is loaded on first access and then cached, so importing the module has no side effects, and every module that imports `ENV` shares one instance. Loading raises a clear error if `__VARLOCK_ENV` is missing (e.g. you forgot `varlock run`). Optional keys that are unset read as `None`.
-## Sharing one instance
+Each attribute is a documented property, so editors using Pyright or Pylance show the item's description, [`@docs()`](/reference/item-decorators/#docs) links, valid enum values, and sensitivity on hover, and go-to-definition jumps to it. Items marked [`@deprecated`](/reference/item-decorators/#deprecated) are typed with [`@deprecated`](https://peps.python.org/pep-0702/), so type checkers flag reads of them and editors show them struck through.
-Every `load_env()` call re-parses the blob. To avoid reloading, load once in a config module and share it, the idiomatic "settings module" pattern:
+To build an instance yourself, for example in tests, pass a dict to `EnvAttrs`:
```python
-# config.py
-from env import load_env
+from env import EnvAttrs
-env = load_env() # loaded once, typed as Env
+env = EnvAttrs({"DB_HOST": "localhost", "APP_ENV": "dev"})
```
+### Dict access
+
+`load_env()` returns the values as a plain `Env` dict instead. Optional keys that are unset are absent from the dict (`NotRequired`), not present as `None`. Each call re-parses the blob, so call it once and share the result:
+
```python
-# anywhere.py
-from config import env
+from env import load_env, SENSITIVE_KEYS
+env = load_env()
port = env["DB_PORT"] # int
+
+# build your own redaction using SENSITIVE_KEYS
+safe = {k: ("***" if k in SENSITIVE_KEYS else v) for k, v in env.items()}
```
+The `Env` fields carry the same docs as docstrings, but Pyright shows them only when hovering the declaration, not on `env["DB_PORT"]`. Use `ENV` if you want docs where values are read.
+
## Running your server
[`varlock run`](/reference/cli/load-and-run/#run) wraps any command, so boot your ASGI server, task runner, or scripts through it. Local dev, CI, and production all use the same schema:
@@ -124,5 +128,5 @@ When using varlock, don't also load `.env` from Python (`python-dotenv`, Pydanti
:::
:::note
-Every key is also injected as a plain environment variable, so you can read any of them with `os.environ` (raw strings) if you don't need the typed module. Keys whose names aren't valid identifiers (they contain `.` or `-`) can't be `TypedDict` fields, so read those from the env var directly. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption.
+Every key is also injected as a plain environment variable, so you can read any of them with `os.environ` (raw strings) if you don't need the typed module. Keys whose names aren't valid identifiers (they contain `.` or `-`, or are a Python keyword) are left out of the typed module, so read those from the env var directly. A few more names can't be `ENV` attributes, so read those with `load_env()`: `property`, `deprecated`, `_values`, `Env`, the type names `str`, `int`, `float`, `bool`, `list`, `dict`, `Any`, and `Literal`, and anything starting with `__`. The generated file lists any it left out. See [Other languages](/integrations/other-languages/) for the shared details on the injected blob, raw env vars, and encryption.
:::
diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
index a39539ca3..43916db42 100644
--- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
+++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
@@ -761,7 +761,7 @@ TypeScript type declarations. Makes `import { ENV } from 'varlock/env'` typed an
### `@generatePythonEnv()`
-A self-contained Python module: a coerced `Env` `TypedDict`, a `load_env()` that parses the injected env, and a `SENSITIVE_KEYS` constant. See [Python usage](/integrations/python/).
+A self-contained Python module: a lazily loaded `ENV` object with a documented, typed attribute per item (so editors show item docs on hover), a coerced `Env` `TypedDict` with a `load_env()` that parses the injected env, and a `SENSITIVE_KEYS` constant. See [Python usage](/integrations/python/).
```env-spec
# @generatePythonEnv(path=./env.py)
diff --git a/packages/varlock/src/env-graph/lib/type-generation/emitters/python.ts b/packages/varlock/src/env-graph/lib/type-generation/emitters/python.ts
index 07c84694a..a8e74dc82 100644
--- a/packages/varlock/src/env-graph/lib/type-generation/emitters/python.ts
+++ b/packages/varlock/src/env-graph/lib/type-generation/emitters/python.ts
@@ -3,9 +3,9 @@ import {
BLOB_ENCRYPTED_MSG,
BLOB_MISSING_MSG,
ENCRYPTED_BLOB_PREFIX,
- getFieldDocLines,
getSensitiveKeys,
partitionRepresentableKeys,
+ sanitizeDocLine,
skippedKeysComment,
type CoercedType,
type ResolvedFieldType,
@@ -86,15 +86,43 @@ function getPythonCoercedTypeString(coerced: CoercedType): string {
return 'str';
}
-function formatPythonField(field: ResolvedFieldType, typeString: string): Array
{
- const docLines = getFieldDocLines(field);
- // single line of docs → inline comment; multiple → comment lines above the field; none → no comment
- if (docLines.length === 1) {
- return [` ${field.key}: ${typeString} # ${docLines[0]}`];
+/**
+ * Docstring content for a field. Pyright/Pylance render docstrings as Markdown on hover, so docs
+ * links become Markdown links and sections are separated by blank lines (consecutive lines would be
+ * reflowed into one paragraph).
+ */
+function getPythonDocstringLines(field: ResolvedFieldType): Array {
+ const { docs } = field;
+ const sections: Array> = [];
+ if (docs.description) sections.push(docs.description.split(/\r\n|\r|\n/));
+ if (docs.docsLinks.length) {
+ sections.push(docs.docsLinks.map((entry) => (
+ entry.description ? `Docs: [${entry.description}](${entry.url})` : `Docs: <${entry.url}>`
+ )));
}
+ if (docs.isDeprecated) {
+ sections.push([docs.deprecationMessage ? `Deprecated: ${docs.deprecationMessage}` : 'Deprecated']);
+ }
+ if (typeof field.coerced === 'object' && 'enum' in field.coerced && field.coerced.enum.length) {
+ sections.push([`Valid values: ${field.coerced.enum.map((o) => JSON.stringify(o)).join(' | ')}`]);
+ }
+ if (field.isSensitive) sections.push(['🔐 Sensitive']);
+ return sections.flatMap((section, i) => [...(i ? [''] : []), ...section.map(sanitizeDocLine)]);
+}
+
+/**
+ * Render docstring lines as a `"""..."""` literal at the given indent (or `[]` if there are none).
+ * Every backslash and quote is escaped, so user text can't close the literal early or form an
+ * escape sequence; editors show the unescaped value on hover.
+ */
+function formatPythonDocstring(docLines: Array, indent: string): Array {
+ if (!docLines.length) return [];
+ const escaped = docLines.map((line) => line.replace(/\\/g, '\\\\').replace(/"/g, '\\"'));
+ if (escaped.length === 1) return [`${indent}"""${escaped[0]}"""`];
return [
- ...docLines.map((line) => ` # ${line}`),
- ` ${field.key}: ${typeString}`,
+ `${indent}"""${escaped[0]}`,
+ ...escaped.slice(1).map((line) => (line ? `${indent}${line}` : '')),
+ `${indent}"""`,
];
}
@@ -102,7 +130,6 @@ function formatPythonTypedDict(
className: string,
classDoc: string,
fields: Array,
- getType: (field: ResolvedFieldType) => string,
): Array {
const lines = [
'',
@@ -111,15 +138,78 @@ function formatPythonTypedDict(
` """${classDoc}"""`,
];
- if (!fields.length) {
- lines.push(' pass');
- return lines;
+ for (const field of fields) {
+ const baseType = getPythonCoercedTypeString(field.coerced);
+ const typeString = field.isRequired ? baseType : `NotRequired[${baseType}]`;
+ // attribute docstring (PEP 257): shown when hovering the declaration
+ lines.push(` ${field.key}: ${typeString}`);
+ lines.push(...formatPythonDocstring(getPythonDocstringLines(field), ' '));
}
+ return lines;
+}
+
+// the attribute-access class stores the dict in this slot
+const ATTRS_VALUES_SLOT = '_values';
+
+// names the attribute-access class body references: a property with one of these names would
+// shadow it for the rest of the class (`property`/`deprecated` decorators at runtime; the type
+// names in later annotations for mypy, e.g. a `str` property breaks `def OTHER(self) -> str`)
+const ATTRS_RESERVED_NAMES = new Set([
+ ATTRS_VALUES_SLOT,
+ 'property',
+ 'deprecated',
+ 'Env',
+ 'str',
+ 'int',
+ 'float',
+ 'bool',
+ 'list',
+ 'dict',
+ 'Any',
+ 'Literal',
+]);
+
+/**
+ * Keys that can't be a property on the attribute-access class: names the class body references
+ * (see above), and `__x` names, which Python mangles inside a class body so `ENV.__x` would never
+ * reach them.
+ */
+function isReservedAttrName(key: string): boolean {
+ return ATTRS_RESERVED_NAMES.has(key) || key.startsWith('__');
+}
+
+function formatPythonEnvAttrs(fields: Array): Array {
+ const lines = [
+ '',
+ '',
+ 'class EnvAttrs:',
+ ' """Attribute access to the coerced env (`ENV.MY_KEY`), so editors show each item\'s docs on hover.',
+ '',
+ ' Unset optional keys read as `None`. Build one from any `Env` dict, e.g. in tests.',
+ ' """',
+ '',
+ ` __slots__ = ("${ATTRS_VALUES_SLOT}",)`,
+ '',
+ ' def __init__(self, values: Env) -> None:',
+ ` self.${ATTRS_VALUES_SLOT} = values`,
+ ];
+
for (const field of fields) {
- const baseType = getType(field);
- const typeString = field.isRequired ? baseType : `NotRequired[${baseType}]`;
- lines.push(...formatPythonField(field, typeString));
+ const baseType = getPythonCoercedTypeString(field.coerced);
+ const typeString = field.isRequired ? baseType : `${baseType} | None`;
+ const getter = field.isRequired
+ ? `self.${ATTRS_VALUES_SLOT}[${JSON.stringify(field.key)}]`
+ : `self.${ATTRS_VALUES_SLOT}.get(${JSON.stringify(field.key)})`;
+ lines.push('');
+ lines.push(' @property');
+ // PEP 702: type checkers flag reads of a deprecated property (editors render a strikethrough)
+ if (field.docs.isDeprecated) {
+ lines.push(` @deprecated(${JSON.stringify(field.docs.deprecationMessage || 'Deprecated')})`);
+ }
+ lines.push(` def ${field.key}(self) -> ${typeString}:`);
+ lines.push(...formatPythonDocstring(getPythonDocstringLines(field), ' '));
+ lines.push(` return ${getter}`);
}
return lines;
@@ -128,6 +218,8 @@ function formatPythonTypedDict(
export function generatePythonEnvSrc(fields: Array): string {
// typed structure covers only representable keys; skipped keys stay in load_env()'s dict at runtime
const { safe, skipped } = partitionRepresentableKeys(fields, (k) => PYTHON_KEYWORDS.has(k));
+ const attrFields = safe.filter((f) => !isReservedAttrName(f.key));
+ const attrSkipped = safe.filter((f) => isReservedAttrName(f.key)).map((f) => f.key);
// SENSITIVE_KEYS covers every sensitive key (incl. skipped ones) — redaction shouldn't miss them
const sensitiveKeys = getSensitiveKeys(fields);
const sensitiveSet = sensitiveKeys.length
@@ -138,18 +230,17 @@ export function generatePythonEnvSrc(fields: Array): string {
// Literal / Any (and subscripts like dict[str, Any]) are never evaluated at runtime — the module
// imports on any Python 3.7+, while type checkers still read the real types from the source. The
// type-only symbols therefore live under `TYPE_CHECKING` (imported only when actually used).
+ // NotRequired / deprecated come from typing_extensions (bundled with every type checker's stubs,
+ // never imported at runtime) so checkers targeting Python < 3.11 / 3.13 accept them too.
const typeStrings = safe.map((f) => getPythonCoercedTypeString(f.coerced));
- const typeOnlyImports: Array = [];
- if (typeStrings.some((t) => t.includes('Any'))) typeOnlyImports.push('Any');
+ const typingImports: Array = [];
+ if (typeStrings.some((t) => t.includes('Any'))) typingImports.push('Any');
// includes (not startsWith): Literal can be nested, e.g. list[Literal["a", "b"]]
- if (typeStrings.some((t) => t.includes('Literal['))) typeOnlyImports.push('Literal');
- if (safe.some((f) => !f.isRequired)) typeOnlyImports.push('NotRequired');
- typeOnlyImports.sort();
-
- // `cast` is called at runtime (identity fn) so it's a real import, not type-only
- const runtimeTypingImports = ['TypedDict', 'cast'];
- if (typeOnlyImports.length) runtimeTypingImports.push('TYPE_CHECKING');
- runtimeTypingImports.sort();
+ if (typeStrings.some((t) => t.includes('Literal['))) typingImports.push('Literal');
+ const typingExtensionsImports: Array = [];
+ if (safe.some((f) => !f.isRequired)) typingExtensionsImports.push('NotRequired');
+ const usesDeprecated = attrFields.some((f) => f.docs.isDeprecated);
+ if (usesDeprecated) typingExtensionsImports.push('deprecated');
const src = [
getAutogeneratedBanner('#'),
@@ -157,19 +248,32 @@ export function generatePythonEnvSrc(fields: Array): string {
'',
'import json',
'import os',
- `from typing import ${runtimeTypingImports.join(', ')}`,
- ...(typeOnlyImports.length ? [
- '',
- 'if TYPE_CHECKING:',
- ` from typing import ${typeOnlyImports.join(', ')}`,
+ // `cast` is called at runtime (identity fn) so it's a real import, not type-only
+ 'from typing import TYPE_CHECKING, TypedDict, cast',
+ '',
+ 'if TYPE_CHECKING:',
+ ...(typingImports.length ? [` from typing import ${typingImports.join(', ')}`] : []),
+ ...(typingExtensionsImports.length ? [` from typing_extensions import ${typingExtensionsImports.join(', ')}`] : []),
+ '',
+ // declared for type checkers only; resolved lazily by the module `__getattr__` below
+ ' ENV: EnvAttrs',
+ ...(usesDeprecated ? [
+ 'else:',
+ ' def deprecated(_message):',
+ ' return lambda fn: fn',
] : []),
...(skipped.length ? ['', ...skippedKeysComment(skipped, '#')] : []),
+ ...(attrSkipped.length ? [
+ '',
+ `# Keys omitted from EnvAttrs (reserved or mangled names): ${attrSkipped.join(', ')}.`,
+ '# Read them from load_env() instead.',
+ ] : []),
...formatPythonTypedDict(
'Env',
'Coerced env schema (matches varlock coercion) — values are typed, not raw strings.',
safe,
- (field) => getPythonCoercedTypeString(field.coerced),
),
+ ...formatPythonEnvAttrs(attrFields),
'',
'',
'# Keys whose values are sensitive — use this to build your own redaction / leak-scanning.',
@@ -191,6 +295,19 @@ export function generatePythonEnvSrc(fields: Array): string {
' {key: entry["value"] for key, entry in graph["config"].items() if "value" in entry},',
' )',
'',
+ '',
+ // hidden from type checkers: a visible module `__getattr__` would make them accept any name
+ // imported from this module (typos included)
+ 'if not TYPE_CHECKING:',
+ ' def __getattr__(name):',
+ ' # `ENV` loads on first access (`from env import ENV`), so importing this module stays',
+ ' # side-effect-free; the instance is cached as a real module attribute afterwards',
+ ' if name == "ENV":',
+ ' env = EnvAttrs(load_env())',
+ ' globals()["ENV"] = env',
+ ' return env',
+ ' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")',
+ '',
];
return src.join('\n');
diff --git a/packages/varlock/src/env-graph/lib/type-generation/shared.ts b/packages/varlock/src/env-graph/lib/type-generation/shared.ts
index 082c19f9a..d1d1704f5 100644
--- a/packages/varlock/src/env-graph/lib/type-generation/shared.ts
+++ b/packages/varlock/src/env-graph/lib/type-generation/shared.ts
@@ -149,6 +149,14 @@ export function assertNoFieldNameCollisions(
}
}
+/**
+ * Strip control chars (user-controlled description / deprecation / link text) so a doc line can't
+ * break out of the generated comment or docstring in any target language.
+ */
+export function sanitizeDocLine(line: string): string {
+ return Array.from(line, (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('').trimEnd();
+}
+
/**
* Language-agnostic doc content lines for a field (description, docs links, deprecation, enum values).
* Deliberately excludes the key name — repeating it as a comment is noise. Returns `[]` when there's
@@ -168,7 +176,5 @@ export function getFieldDocLines(field: ResolvedFieldType): Array {
if (typeof field.coerced === 'object' && 'enum' in field.coerced && field.coerced.enum.length) {
lines.push(`Valid values: ${field.coerced.enum.map((o) => JSON.stringify(o)).join(' | ')}`);
}
- // strip any remaining control chars (user-controlled description / deprecation / link text) so a
- // line can't break out of the generated comment in any target language
- return lines.map((line) => Array.from(line, (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('').trimEnd());
+ return lines.map(sanitizeDocLine);
}
diff --git a/packages/varlock/src/env-graph/test/type-generation/python.test.ts b/packages/varlock/src/env-graph/test/type-generation/python.test.ts
index 2f51b9c04..7660d6af7 100644
--- a/packages/varlock/src/env-graph/test/type-generation/python.test.ts
+++ b/packages/varlock/src/env-graph/test/type-generation/python.test.ts
@@ -77,22 +77,118 @@ describe('generatePythonEnvSrc', () => {
expect(src).toContain('frozenset({"MY-KEY"})');
});
- test('descriptions become comments (no fragile docstrings to escape)', async () => {
+ test('docs become attribute docstrings, with quotes and backslashes escaped', async () => {
const g = await loadGraph({
envFile: outdent`
# @defaultSensitive=false
# ---
- # value with """ quotes inside
+ # value with """ quotes and a back\\slash
+ # second line
+ # @docs("Setup guide", https://example.com/setup)
+ # @docs(https://example.com/ref)
QUOTED=val # @public @required
+ # one-liner
+ SHORT=val # @public @optional
`,
});
- const items = [await g.configSchema.QUOTED.getTypeGenInfo()];
+ const items = [
+ await g.configSchema.QUOTED.getTypeGenInfo(),
+ await g.configSchema.SHORT.getTypeGenInfo(),
+ ];
const src = generatePythonEnvSrc(resolveFieldTypes(items));
- // rendered as a `#` comment — a triple-quote in the text can't break the file
- expect(src).toContain('QUOTED: str # value with """ quotes inside');
+ // every quote is escaped, so the text can't close the docstring early
+ const quotedDoc = [
+ '"""value with \\"\\"\\" quotes and a back\\\\slash',
+ 'second line',
+ '',
+ 'Docs: [Setup guide](https://example.com/setup)',
+ 'Docs: ',
+ '"""',
+ ];
+ expect(src).toContain([' QUOTED: str', ...quotedDoc.map((l) => (l ? ` ${l}` : ''))].join('\n'));
+ expect(src).toContain(' SHORT: NotRequired[str]\n """one-liner"""');
+ // no `#` field comments anymore: tooling ignores comments
+ expect(src).not.toContain('# value with');
+
+ // the same docs land on the EnvAttrs property, where hover works at the usage site
+ expect(src).toContain([
+ ' @property',
+ ' def QUOTED(self) -> str:',
+ ...quotedDoc.map((l) => (l ? ` ${l}` : '')),
+ ' return self._values["QUOTED"]',
+ ].join('\n'));
+ expect(src).toContain(' def SHORT(self) -> str | None:\n """one-liner"""\n return self._values.get("SHORT")');
+ });
+
+ test('EnvAttrs class and a lazily-loaded ENV for attribute access', async () => {
+ const { fields } = await loadFixtureFields();
+ const src = generatePythonEnvSrc(fields);
+
+ expect(src).toContain('class EnvAttrs:');
+ expect(src).toContain(' def __init__(self, values: Env) -> None:');
+ expect(src).toContain(' def APP_ENV(self) -> Literal["dev", "staging", "prod"]:');
+ expect(src).toContain(' def DB_PORT(self) -> int | None:');
+ // sensitive items are marked in their docs
+ expect(src).toMatch(/def API_KEY\(self\) -> str:\n {8}"""🔐 Sensitive"""/);
+
+ // ENV is declared for type checkers; the module __getattr__ that loads it is hidden from them
+ expect(src).toContain('if TYPE_CHECKING:');
+ expect(src).toContain(' ENV: EnvAttrs');
+ expect(src).toContain('if not TYPE_CHECKING:\n def __getattr__(name):');
+ expect(src).toContain('globals()["ENV"] = env');
+ // import stays side-effect-free
+ expect(src).not.toMatch(/^ENV = /m);
+ });
+
+ test('deprecated items map to typing_extensions.deprecated on the property', async () => {
+ const { fields } = await loadFixtureFields(outdent`
+ # @defaultSensitive=false
+ # ---
+ # @deprecated="use NEW_KEY"
+ OLD_KEY=a # @public @required
+ # @deprecated
+ BARE_OLD=b # @public @required
+ `);
+ const src = generatePythonEnvSrc(fields);
+ expect(src).toContain(' from typing_extensions import deprecated');
+ // runtime no-op fallback: the module has no dependencies
+ expect(src).toContain('else:\n def deprecated(_message):\n return lambda fn: fn');
+ expect(src).toContain(' @property\n @deprecated("use NEW_KEY")\n def OLD_KEY(self) -> str:');
+ expect(src).toContain(' @deprecated("Deprecated")\n def BARE_OLD(self) -> str:');
+ expect(src).toContain('Deprecated: use NEW_KEY');
+
+ // no deprecated items: no import and no fallback
+ const plain = generatePythonEnvSrc((await loadFixtureFields()).fields);
+ expect(plain).not.toContain('deprecated');
});
+
+ test('keys that would break the EnvAttrs class body are left off it (still in Env)', async () => {
+ const { fields } = await loadFixtureFields(outdent`
+ # @defaultSensitive=false
+ # ---
+ property=a # @public @required
+ _values=b # @public @required
+ __MANGLED=c # @public @required
+ str=e # @public @required
+ Env=f # @public @required
+ OK=d # @public @optional
+ `);
+ const src = generatePythonEnvSrc(fields);
+ expect(src).toContain(' property: str');
+ expect(src).toContain(' __MANGLED: str');
+ expect(src).not.toContain('def property(');
+ expect(src).not.toContain('def _values(');
+ expect(src).not.toContain('def __MANGLED(');
+ // a `str` / `Env` property would shadow the type in later annotations (mypy rejects it)
+ expect(src).not.toContain('def str(');
+ expect(src).not.toContain('def Env(');
+ expect(src).toContain('def OK(self) -> str | None:');
+ expect(src).toContain('def __init__(self, values: Env) -> None:');
+ expect(src).toContain('# Keys omitted from EnvAttrs (reserved or mangled names): property, _values, __MANGLED, str, Env.');
+ });
+
test('composite (array/object) types map to list/dict annotations', async () => {
const { fields } = await loadFixtureFields(COMPOSITE_TYPE_FIXTURE);
const src = generatePythonEnvSrc(fields);
@@ -101,6 +197,6 @@ describe('generatePythonEnvSrc', () => {
expect(src).toContain('MODES: NotRequired[list[Literal["dev", "prod"]]]');
expect(src).toContain('LIMITS: NotRequired[dict[str, float]]');
// Literal only appears nested here — the import detection must still catch it
- expect(src).toContain('from typing import Literal');
+ expect(src).toContain(' from typing import Literal');
});
});