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.
@@ -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.
@@ -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