pyenvgen 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyenvgen-0.1.0/PKG-INFO +119 -0
- pyenvgen-0.1.0/README.md +104 -0
- pyenvgen-0.1.0/pyproject.toml +33 -0
- pyenvgen-0.1.0/src/pyenvgen/__init__.py +1 -0
- pyenvgen-0.1.0/src/pyenvgen/cli.py +118 -0
- pyenvgen-0.1.0/src/pyenvgen/generation/__init__.py +215 -0
- pyenvgen-0.1.0/src/pyenvgen/generation/command.py +19 -0
- pyenvgen-0.1.0/src/pyenvgen/generation/default.py +10 -0
- pyenvgen-0.1.0/src/pyenvgen/generation/openssl.py +164 -0
- pyenvgen-0.1.0/src/pyenvgen/py.typed +0 -0
- pyenvgen-0.1.0/src/pyenvgen/schema.py +163 -0
- pyenvgen-0.1.0/src/pyenvgen/storage/__init__.py +100 -0
- pyenvgen-0.1.0/src/pyenvgen/storage/dotenv.py +78 -0
- pyenvgen-0.1.0/src/pyenvgen/storage/json.py +53 -0
- pyenvgen-0.1.0/src/pyenvgen/storage/stdout.py +25 -0
- pyenvgen-0.1.0/src/pyenvgen/storage/toml.py +53 -0
- pyenvgen-0.1.0/src/pyenvgen/storage/yaml.py +58 -0
- pyenvgen-0.1.0/src/pyenvgen/validation/__init__.py +77 -0
pyenvgen-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyenvgen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python tool to generate environment variables from schemas
|
|
5
|
+
Author: bastienlc
|
|
6
|
+
Author-email: bastienlc <78959054+bastienlc@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: pyyaml>=6.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: marshmallow>=3.0
|
|
10
|
+
Requires-Dist: jinja2>=3.0
|
|
11
|
+
Requires-Dist: cryptography>=41.0
|
|
12
|
+
Requires-Dist: tomli-w>=1.0
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pyenvgen
|
|
17
|
+
|
|
18
|
+
> **Warning:** This project was vibe coded and is in early development. Expect breaking changes and incomplete features. Contributions are welcome!
|
|
19
|
+
|
|
20
|
+
Python tool to generate environment variables from YAML schemas.
|
|
21
|
+
|
|
22
|
+
1. Define variables in a YAML schema, including types, generation rules, and validation constraints.
|
|
23
|
+
2. Existing values are loaded from the chosen storage backend.
|
|
24
|
+
3. Values are generated (with existing/overridden values taking precedence), then validated.
|
|
25
|
+
4. The final environment is written back to the storage backend.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pyenvgen
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pyenvgen <schema.yaml> [-s STORAGE] [-o KEY=VALUE ...] [--force]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Flag | Description |
|
|
40
|
+
| ------------------ | ------------------------------------------------------ |
|
|
41
|
+
| `-s`, `--storage` | Storage backend (default: `stdout`) |
|
|
42
|
+
| `-o`, `--override` | Override a value via `KEY=VALUE` (repeatable) |
|
|
43
|
+
| `--force` | Regenerate all values, ignoring existing stored values |
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Print to stdout
|
|
47
|
+
pyenvgen examples/basic.yaml
|
|
48
|
+
|
|
49
|
+
# Write to a .env file
|
|
50
|
+
pyenvgen examples/basic.yaml -s .env
|
|
51
|
+
|
|
52
|
+
# Override a value
|
|
53
|
+
pyenvgen examples/basic.yaml -o APP_PORT=9000
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Schema format
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
variables:
|
|
60
|
+
MY_VAR:
|
|
61
|
+
type: str # str | int | float | bool (default: str)
|
|
62
|
+
description: "..."
|
|
63
|
+
internal: false # default: false
|
|
64
|
+
generation:
|
|
65
|
+
rule: default
|
|
66
|
+
value: "hello"
|
|
67
|
+
validation:
|
|
68
|
+
length:
|
|
69
|
+
min: 1
|
|
70
|
+
max: 128
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Variable types
|
|
74
|
+
|
|
75
|
+
`str`, `int`, `float`, `bool`
|
|
76
|
+
|
|
77
|
+
### Generation rules
|
|
78
|
+
|
|
79
|
+
All string fields in generation rules are rendered as **Jinja2 templates** against already-generated values before execution.
|
|
80
|
+
|
|
81
|
+
- **`default`** – use a static value (supports Jinja2, e.g. `"postgres://{{ HOST }}:{{ PORT }}/app"`)
|
|
82
|
+
- **`command`** – run a shell command and capture its stdout
|
|
83
|
+
- **`openssl`** – generate cryptographic material via the [`cryptography`](https://github.com/pyca/cryptography) package
|
|
84
|
+
|
|
85
|
+
#### `openssl` commands
|
|
86
|
+
|
|
87
|
+
| Command | Description | Key args |
|
|
88
|
+
| --------- | ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `rsa` | RSA private key (PEM or DER base64) | `key_size` (default 2048), `encoding` (`pem`\|`der_b64`) |
|
|
90
|
+
| `ec` | EC private key | `curve` (`secp256r1`\|`secp384r1`\|`secp521r1`\|`secp256k1`), `encoding` (`pem`\|`der_b64`) |
|
|
91
|
+
| `ed25519` | Ed25519 private key | `encoding` (`pem`\|`raw_b64`) |
|
|
92
|
+
| `x25519` | X25519 private key | `encoding` (`pem`\|`raw_b64`, default `raw_b64`) |
|
|
93
|
+
| `fernet` | URL-safe base64 Fernet key | — |
|
|
94
|
+
| `random` | Random bytes | `length` (default 32), `encoding` (`hex`\|`base64`\|`base64url`) |
|
|
95
|
+
|
|
96
|
+
### Validation rules
|
|
97
|
+
|
|
98
|
+
Backed by [marshmallow](https://github.com/marshmallow-code/marshmallow).
|
|
99
|
+
|
|
100
|
+
| Rule | Applies to | Fields |
|
|
101
|
+
| -------- | -------------- | ---------------------------------------------- |
|
|
102
|
+
| `length` | `str` | `min`, `max` |
|
|
103
|
+
| `range` | `int`, `float` | `min`, `max`, `min_inclusive`, `max_inclusive` |
|
|
104
|
+
| `one_of` | any | `choices: [...]` |
|
|
105
|
+
| `regexp` | any | `pattern` |
|
|
106
|
+
|
|
107
|
+
### Storage backends
|
|
108
|
+
|
|
109
|
+
| Value | Description |
|
|
110
|
+
| -------- | ------------------------ |
|
|
111
|
+
| `stdout` | Print to standard output |
|
|
112
|
+
| `.env` | Read/write a `.env` file |
|
|
113
|
+
| `json` | Read/write a JSON file |
|
|
114
|
+
| `toml` | Read/write a TOML file |
|
|
115
|
+
| `yaml` | Read/write a YAML file |
|
|
116
|
+
|
|
117
|
+
### Special properties
|
|
118
|
+
|
|
119
|
+
- **`internal: true`** – the variable is generated and available to Jinja2 templates but excluded from the output.
|
pyenvgen-0.1.0/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# pyenvgen
|
|
2
|
+
|
|
3
|
+
> **Warning:** This project was vibe coded and is in early development. Expect breaking changes and incomplete features. Contributions are welcome!
|
|
4
|
+
|
|
5
|
+
Python tool to generate environment variables from YAML schemas.
|
|
6
|
+
|
|
7
|
+
1. Define variables in a YAML schema, including types, generation rules, and validation constraints.
|
|
8
|
+
2. Existing values are loaded from the chosen storage backend.
|
|
9
|
+
3. Values are generated (with existing/overridden values taking precedence), then validated.
|
|
10
|
+
4. The final environment is written back to the storage backend.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install pyenvgen
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pyenvgen <schema.yaml> [-s STORAGE] [-o KEY=VALUE ...] [--force]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Flag | Description |
|
|
25
|
+
| ------------------ | ------------------------------------------------------ |
|
|
26
|
+
| `-s`, `--storage` | Storage backend (default: `stdout`) |
|
|
27
|
+
| `-o`, `--override` | Override a value via `KEY=VALUE` (repeatable) |
|
|
28
|
+
| `--force` | Regenerate all values, ignoring existing stored values |
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Print to stdout
|
|
32
|
+
pyenvgen examples/basic.yaml
|
|
33
|
+
|
|
34
|
+
# Write to a .env file
|
|
35
|
+
pyenvgen examples/basic.yaml -s .env
|
|
36
|
+
|
|
37
|
+
# Override a value
|
|
38
|
+
pyenvgen examples/basic.yaml -o APP_PORT=9000
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Schema format
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
variables:
|
|
45
|
+
MY_VAR:
|
|
46
|
+
type: str # str | int | float | bool (default: str)
|
|
47
|
+
description: "..."
|
|
48
|
+
internal: false # default: false
|
|
49
|
+
generation:
|
|
50
|
+
rule: default
|
|
51
|
+
value: "hello"
|
|
52
|
+
validation:
|
|
53
|
+
length:
|
|
54
|
+
min: 1
|
|
55
|
+
max: 128
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Variable types
|
|
59
|
+
|
|
60
|
+
`str`, `int`, `float`, `bool`
|
|
61
|
+
|
|
62
|
+
### Generation rules
|
|
63
|
+
|
|
64
|
+
All string fields in generation rules are rendered as **Jinja2 templates** against already-generated values before execution.
|
|
65
|
+
|
|
66
|
+
- **`default`** – use a static value (supports Jinja2, e.g. `"postgres://{{ HOST }}:{{ PORT }}/app"`)
|
|
67
|
+
- **`command`** – run a shell command and capture its stdout
|
|
68
|
+
- **`openssl`** – generate cryptographic material via the [`cryptography`](https://github.com/pyca/cryptography) package
|
|
69
|
+
|
|
70
|
+
#### `openssl` commands
|
|
71
|
+
|
|
72
|
+
| Command | Description | Key args |
|
|
73
|
+
| --------- | ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `rsa` | RSA private key (PEM or DER base64) | `key_size` (default 2048), `encoding` (`pem`\|`der_b64`) |
|
|
75
|
+
| `ec` | EC private key | `curve` (`secp256r1`\|`secp384r1`\|`secp521r1`\|`secp256k1`), `encoding` (`pem`\|`der_b64`) |
|
|
76
|
+
| `ed25519` | Ed25519 private key | `encoding` (`pem`\|`raw_b64`) |
|
|
77
|
+
| `x25519` | X25519 private key | `encoding` (`pem`\|`raw_b64`, default `raw_b64`) |
|
|
78
|
+
| `fernet` | URL-safe base64 Fernet key | — |
|
|
79
|
+
| `random` | Random bytes | `length` (default 32), `encoding` (`hex`\|`base64`\|`base64url`) |
|
|
80
|
+
|
|
81
|
+
### Validation rules
|
|
82
|
+
|
|
83
|
+
Backed by [marshmallow](https://github.com/marshmallow-code/marshmallow).
|
|
84
|
+
|
|
85
|
+
| Rule | Applies to | Fields |
|
|
86
|
+
| -------- | -------------- | ---------------------------------------------- |
|
|
87
|
+
| `length` | `str` | `min`, `max` |
|
|
88
|
+
| `range` | `int`, `float` | `min`, `max`, `min_inclusive`, `max_inclusive` |
|
|
89
|
+
| `one_of` | any | `choices: [...]` |
|
|
90
|
+
| `regexp` | any | `pattern` |
|
|
91
|
+
|
|
92
|
+
### Storage backends
|
|
93
|
+
|
|
94
|
+
| Value | Description |
|
|
95
|
+
| -------- | ------------------------ |
|
|
96
|
+
| `stdout` | Print to standard output |
|
|
97
|
+
| `.env` | Read/write a `.env` file |
|
|
98
|
+
| `json` | Read/write a JSON file |
|
|
99
|
+
| `toml` | Read/write a TOML file |
|
|
100
|
+
| `yaml` | Read/write a YAML file |
|
|
101
|
+
|
|
102
|
+
### Special properties
|
|
103
|
+
|
|
104
|
+
- **`internal: true`** – the variable is generated and available to Jinja2 templates but excluded from the output.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyenvgen"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python tool to generate environment variables from schemas"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "bastienlc", email = "78959054+bastienlc@users.noreply.github.com" },
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pyyaml>=6.0",
|
|
12
|
+
"pydantic>=2.0",
|
|
13
|
+
"marshmallow>=3.0",
|
|
14
|
+
"jinja2>=3.0",
|
|
15
|
+
"cryptography>=41.0",
|
|
16
|
+
"tomli-w>=1.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
pyenvgen = "pyenvgen.cli:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.9.20,<0.10.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = ["pytest>=9.0.2", "pytest-cov>=6.0", "ruff>=0.15.2", "pre-commit>=4.0"]
|
|
28
|
+
|
|
29
|
+
[tool.coverage.run]
|
|
30
|
+
source = ["src/pyenvgen"]
|
|
31
|
+
|
|
32
|
+
[tool.coverage.report]
|
|
33
|
+
fail_under = 80
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pyenvgen - Python tool to generate environment variables from schemas."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""CLI entry-point for pyenvgen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from marshmallow import ValidationError as MarshmallowValidationError
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from pyenvgen.generation import generate_env
|
|
15
|
+
from pyenvgen.schema import EnvSchema
|
|
16
|
+
from pyenvgen.storage import get_storage
|
|
17
|
+
from pyenvgen.validation import validate_env
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_schema(path: Path) -> EnvSchema:
|
|
21
|
+
"""Load and validate a YAML schema file."""
|
|
22
|
+
try:
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
raw: Any = yaml.safe_load(f)
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
print(f"Error: schema file not found: {path}")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
if not isinstance(raw, dict):
|
|
30
|
+
print(f"Error: schema file must be a YAML mapping, got {type(raw).__name__}")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
return EnvSchema.model_validate(raw)
|
|
35
|
+
except ValidationError as exc:
|
|
36
|
+
print(f"Schema validation error:\n{exc}")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_overrides(override_args: list[str] | None) -> dict[str, str]:
|
|
41
|
+
"""Parse ``KEY=VALUE`` override arguments from the CLI."""
|
|
42
|
+
overrides: dict[str, str] = {}
|
|
43
|
+
for item in override_args or []:
|
|
44
|
+
if "=" not in item:
|
|
45
|
+
print(f"Error: override must be KEY=VALUE, got '{item}'")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
key, value = item.split("=", 1)
|
|
48
|
+
overrides[key] = value
|
|
49
|
+
return overrides
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> None:
|
|
53
|
+
"""Main entry-point."""
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="pyenvgen",
|
|
56
|
+
description="Generate environment variables from a YAML schema.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"schema",
|
|
60
|
+
type=Path,
|
|
61
|
+
help="Path to the YAML schema file.",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"-s",
|
|
65
|
+
"--storage",
|
|
66
|
+
default="stdout",
|
|
67
|
+
help="Storage backend (default: stdout).",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-o",
|
|
71
|
+
"--override",
|
|
72
|
+
action="append",
|
|
73
|
+
metavar="KEY=VALUE",
|
|
74
|
+
help="Override a generated value (can be repeated).",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--force",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help=(
|
|
80
|
+
"Regenerate all values, ignoring any values already present in "
|
|
81
|
+
"the storage backend. Without this flag, existing values are "
|
|
82
|
+
"preserved and used as the base before new ones are generated."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
|
|
88
|
+
# 1. Load & validate schema
|
|
89
|
+
schema = _load_schema(args.schema)
|
|
90
|
+
|
|
91
|
+
# 2. Get storage backend early so we can load existing values
|
|
92
|
+
backend = get_storage(args.storage)
|
|
93
|
+
|
|
94
|
+
# 3. Parse CLI overrides
|
|
95
|
+
overrides = _parse_overrides(args.override)
|
|
96
|
+
|
|
97
|
+
# 4. Load existing values from storage and merge with CLI overrides.
|
|
98
|
+
# This happens *before* generation so that existing values are available
|
|
99
|
+
# to Jinja templates inside generation rules. CLI overrides always take
|
|
100
|
+
# precedence over stored values; --force skips loading altogether.
|
|
101
|
+
if args.force:
|
|
102
|
+
merged: dict[str, str] = overrides
|
|
103
|
+
else:
|
|
104
|
+
existing = backend.load()
|
|
105
|
+
merged = {**existing, **overrides}
|
|
106
|
+
|
|
107
|
+
# 5. Generate values (existing / overridden values seed the result dict)
|
|
108
|
+
generated = generate_env(schema, overrides=merged)
|
|
109
|
+
|
|
110
|
+
# 6. Validate generated values against schema (type-cast + constraints)
|
|
111
|
+
try:
|
|
112
|
+
validated = validate_env(schema, generated)
|
|
113
|
+
except MarshmallowValidationError as exc:
|
|
114
|
+
print(f"Validation error: {exc}")
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
# 7. Store output
|
|
118
|
+
backend.store(validated, schema)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Generation module – produces environment variable values from schemas.
|
|
2
|
+
|
|
3
|
+
Each generation rule lives in its own submodule:
|
|
4
|
+
- ``default`` → :mod:`pyenvgen.generation.default`
|
|
5
|
+
- ``command`` → :mod:`pyenvgen.generation.command`
|
|
6
|
+
- ``openssl`` → :mod:`pyenvgen.generation.openssl`
|
|
7
|
+
|
|
8
|
+
All rule string fields are rendered as Jinja2 templates against the set of
|
|
9
|
+
already-generated variables *before* the rule is executed. This means any
|
|
10
|
+
rule can reference other variables using ``{{ VAR_NAME }}`` syntax, removing
|
|
11
|
+
the need for a separate ``template`` rule.
|
|
12
|
+
|
|
13
|
+
This package exposes the public API: :func:`generate_value` and
|
|
14
|
+
:func:`generate_env`. It also handles topological ordering so that
|
|
15
|
+
variables whose Jinja templates reference others are always generated after
|
|
16
|
+
the variables they depend on.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from graphlib import CycleError, TopologicalSorter
|
|
22
|
+
|
|
23
|
+
import jinja2
|
|
24
|
+
import jinja2.meta
|
|
25
|
+
|
|
26
|
+
from pyenvgen.schema import (
|
|
27
|
+
CommandGeneration,
|
|
28
|
+
DefaultGeneration,
|
|
29
|
+
EnvSchema,
|
|
30
|
+
GenerationRule,
|
|
31
|
+
OpenSSLGeneration,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from pyenvgen.generation.command import generate_command
|
|
35
|
+
from pyenvgen.generation.default import generate_default
|
|
36
|
+
from pyenvgen.generation.openssl import generate_openssl
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Errors
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CircularDependencyError(Exception):
|
|
45
|
+
"""Raised when variables form a circular Jinja dependency."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Jinja helpers
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _render_string(s: str, existing: dict[str, str]) -> str:
|
|
54
|
+
"""Render *s* as a Jinja2 template against *existing* values."""
|
|
55
|
+
env = jinja2.Environment(undefined=jinja2.StrictUndefined)
|
|
56
|
+
return env.from_string(s).render(**existing)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _jinja_deps(s: str, all_names: set[str]) -> set[str]:
|
|
60
|
+
"""Return schema variable names referenced in Jinja template string *s*."""
|
|
61
|
+
env = jinja2.Environment()
|
|
62
|
+
ast = env.parse(s)
|
|
63
|
+
refs = jinja2.meta.find_undeclared_variables(ast)
|
|
64
|
+
return refs & all_names
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _rule_template_strings(rule: GenerationRule) -> list[str]:
|
|
68
|
+
"""Return all string fields of *rule* that may contain Jinja templates."""
|
|
69
|
+
if isinstance(rule, DefaultGeneration):
|
|
70
|
+
return [rule.value]
|
|
71
|
+
if isinstance(rule, CommandGeneration):
|
|
72
|
+
return [rule.command]
|
|
73
|
+
if isinstance(rule, OpenSSLGeneration):
|
|
74
|
+
strings = [rule.command]
|
|
75
|
+
strings.extend(v for v in rule.args.values() if isinstance(v, str))
|
|
76
|
+
return strings
|
|
77
|
+
return [] # pragma: no cover
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rule_jinja_deps(rule: GenerationRule, all_names: set[str]) -> set[str]:
|
|
81
|
+
"""Find all schema variable names referenced in Jinja templates within *rule*."""
|
|
82
|
+
deps: set[str] = set()
|
|
83
|
+
for s in _rule_template_strings(rule):
|
|
84
|
+
deps |= _jinja_deps(s, all_names)
|
|
85
|
+
return deps
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_rule(rule: GenerationRule, existing: dict[str, str]) -> GenerationRule:
|
|
89
|
+
"""Return a copy of *rule* with all string fields Jinja-rendered against *existing*."""
|
|
90
|
+
if isinstance(rule, DefaultGeneration):
|
|
91
|
+
return rule.model_copy(update={"value": _render_string(rule.value, existing)})
|
|
92
|
+
if isinstance(rule, CommandGeneration):
|
|
93
|
+
return rule.model_copy(update={"command": _render_string(rule.command, existing)})
|
|
94
|
+
if isinstance(rule, OpenSSLGeneration):
|
|
95
|
+
rendered_args = {
|
|
96
|
+
k: _render_string(v, existing) if isinstance(v, str) else v
|
|
97
|
+
for k, v in rule.args.items()
|
|
98
|
+
}
|
|
99
|
+
return rule.model_copy(
|
|
100
|
+
update={
|
|
101
|
+
"command": _render_string(rule.command, existing),
|
|
102
|
+
"args": rendered_args,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return rule # pragma: no cover
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Topological ordering
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _topological_order(schema: EnvSchema) -> list[str]:
|
|
114
|
+
"""Return variable names in a safe generation order.
|
|
115
|
+
|
|
116
|
+
Every rule's string fields are scanned for Jinja variable references.
|
|
117
|
+
A variable X depends on Y if any of X's template strings reference Y.
|
|
118
|
+
|
|
119
|
+
Raises
|
|
120
|
+
------
|
|
121
|
+
CircularDependencyError
|
|
122
|
+
If the dependency graph contains a cycle.
|
|
123
|
+
"""
|
|
124
|
+
all_names = set(schema.variables)
|
|
125
|
+
|
|
126
|
+
deps: dict[str, set[str]] = {
|
|
127
|
+
name: _rule_jinja_deps(var.generation, all_names)
|
|
128
|
+
for name, var in schema.variables.items()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return list(TopologicalSorter(deps).static_order())
|
|
133
|
+
except CycleError as exc:
|
|
134
|
+
raise CircularDependencyError(
|
|
135
|
+
f"Circular dependency detected among variables: {exc.args[1]}"
|
|
136
|
+
) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Public API
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def generate_value(
|
|
145
|
+
rule: GenerationRule,
|
|
146
|
+
existing: dict[str, str] | None = None,
|
|
147
|
+
) -> str:
|
|
148
|
+
"""Dispatch to the appropriate generator for *rule*.
|
|
149
|
+
|
|
150
|
+
Before dispatching, all string fields in *rule* are rendered as Jinja2
|
|
151
|
+
templates against *existing* so that any rule may reference previously
|
|
152
|
+
generated variables.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
rule
|
|
157
|
+
The generation rule from the variable schema.
|
|
158
|
+
existing
|
|
159
|
+
Already-generated values used for Jinja template rendering.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
str
|
|
164
|
+
The generated value as a string (will be validated/cast later).
|
|
165
|
+
"""
|
|
166
|
+
existing = existing or {}
|
|
167
|
+
rule = _render_rule(rule, existing)
|
|
168
|
+
|
|
169
|
+
if rule.rule == "default":
|
|
170
|
+
assert isinstance(rule, DefaultGeneration)
|
|
171
|
+
return generate_default(rule)
|
|
172
|
+
if rule.rule == "command":
|
|
173
|
+
assert isinstance(rule, CommandGeneration)
|
|
174
|
+
return generate_command(rule)
|
|
175
|
+
if rule.rule == "openssl":
|
|
176
|
+
assert isinstance(rule, OpenSSLGeneration)
|
|
177
|
+
return generate_openssl(rule)
|
|
178
|
+
|
|
179
|
+
raise NotImplementedError(f"Generation rule '{rule.rule}' is not yet implemented") # pragma: no cover
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def generate_env(
|
|
183
|
+
schema: EnvSchema,
|
|
184
|
+
overrides: dict[str, str] | None = None,
|
|
185
|
+
) -> dict[str, str]:
|
|
186
|
+
"""Generate all environment variable values from *schema*.
|
|
187
|
+
|
|
188
|
+
Variables are generated in topological order so that Jinja templates can
|
|
189
|
+
safely reference other variables.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
schema
|
|
194
|
+
The parsed environment schema.
|
|
195
|
+
overrides
|
|
196
|
+
Values that take precedence over generated ones (e.g. from an
|
|
197
|
+
existing .env file or CLI arguments).
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
dict[str, str]
|
|
202
|
+
Mapping of variable name → generated string value.
|
|
203
|
+
"""
|
|
204
|
+
overrides = overrides or {}
|
|
205
|
+
result: dict[str, str] = {}
|
|
206
|
+
|
|
207
|
+
for name in _topological_order(schema):
|
|
208
|
+
if name in overrides:
|
|
209
|
+
result[name] = overrides[name]
|
|
210
|
+
else:
|
|
211
|
+
result[name] = generate_value(
|
|
212
|
+
schema.variables[name].generation, existing=result
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return result
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Command generation rule – executes a shell command and captures its stdout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from pyenvgen.schema import CommandGeneration
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_command(rule: CommandGeneration) -> str:
|
|
11
|
+
"""Execute *rule.command* in a shell and return its stripped stdout."""
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
rule.command,
|
|
14
|
+
shell=True,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
check=True,
|
|
18
|
+
)
|
|
19
|
+
return result.stdout.strip()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Default generation rule – returns a literal value from the schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pyenvgen.schema import DefaultGeneration
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_default(rule: DefaultGeneration) -> str:
|
|
9
|
+
"""Return the literal default value from the schema."""
|
|
10
|
+
return rule.value
|