envshield 4.6.2__tar.gz → 4.7.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.
Files changed (89) hide show
  1. {envshield-4.6.2 → envshield-4.7.0}/PKG-INFO +62 -5
  2. {envshield-4.6.2 → envshield-4.7.0}/README.md +61 -4
  3. envshield-4.7.0/envshield/__init__.py +1 -0
  4. {envshield-4.6.2 → envshield-4.7.0}/envshield/cli.py +203 -55
  5. {envshield-4.6.2 → envshield-4.7.0}/envshield/config/manager.py +100 -24
  6. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/dependency_snapshot.py +33 -6
  7. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/discovery.py +245 -31
  8. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/doctor.py +169 -18
  9. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/explain.py +106 -26
  10. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/importer.py +16 -1
  11. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/scanner.py +27 -4
  12. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/schema_manager.py +371 -5
  13. envshield-4.7.0/envshield/parsers/_deployment.py +126 -0
  14. {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_docker_compose.py +23 -14
  15. envshield-4.7.0/envshield/parsers/_kubernetes.py +222 -0
  16. envshield-4.7.0/envshield/utils/paths.py +49 -0
  17. {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/PKG-INFO +62 -5
  18. envshield-4.7.0/envshield.egg-info/SOURCES.txt +46 -0
  19. {envshield-4.6.2 → envshield-4.7.0}/pyproject.toml +2 -1
  20. envshield-4.6.2/envshield/__init__.py +0 -1
  21. envshield-4.6.2/envshield/config/__init.py +0 -0
  22. envshield-4.6.2/envshield/parsers/_deployment.py +0 -63
  23. envshield-4.6.2/envshield/parsers/_kubernetes.py +0 -148
  24. envshield-4.6.2/envshield/tests/conftest.py +0 -33
  25. envshield-4.6.2/envshield/tests/core/test_contract_diff.py +0 -433
  26. envshield-4.6.2/envshield/tests/core/test_dependency_diff.py +0 -167
  27. envshield-4.6.2/envshield/tests/core/test_dependency_snapshot.py +0 -528
  28. envshield-4.6.2/envshield/tests/core/test_discovery.py +0 -574
  29. envshield-4.6.2/envshield/tests/core/test_doctor.py +0 -736
  30. envshield-4.6.2/envshield/tests/core/test_explain.py +0 -397
  31. envshield-4.6.2/envshield/tests/core/test_file_updater.py +0 -223
  32. envshield-4.6.2/envshield/tests/core/test_generator.py +0 -815
  33. envshield-4.6.2/envshield/tests/core/test_hook_injection.py +0 -282
  34. envshield-4.6.2/envshield/tests/core/test_hooks_manager.py +0 -37
  35. envshield-4.6.2/envshield/tests/core/test_importer.py +0 -808
  36. envshield-4.6.2/envshield/tests/core/test_post_merge_hook.py +0 -139
  37. envshield-4.6.2/envshield/tests/core/test_pre_commit_hook.py +0 -332
  38. envshield-4.6.2/envshield/tests/core/test_scanner_compliance.py +0 -1419
  39. envshield-4.6.2/envshield/tests/core/test_schema_manager.py +0 -1308
  40. envshield-4.6.2/envshield/tests/core/test_schema_snapshot.py +0 -424
  41. envshield-4.6.2/envshield/tests/core/test_schema_types.py +0 -347
  42. envshield-4.6.2/envshield/tests/core/test_service_discovery.py +0 -406
  43. envshield-4.6.2/envshield/tests/core/test_service_manager.py +0 -326
  44. envshield-4.6.2/envshield/tests/core/test_setup_manager.py +0 -1084
  45. envshield-4.6.2/envshield/tests/parsers/__init__.py +0 -0
  46. envshield-4.6.2/envshield/tests/parsers/test_docker_compose_parser.py +0 -436
  47. envshield-4.6.2/envshield/tests/parsers/test_dotenv_parser.py +0 -220
  48. envshield-4.6.2/envshield/tests/parsers/test_kubernetes_parser.py +0 -354
  49. envshield-4.6.2/envshield/tests/parsers/test_python_parser.py +0 -113
  50. envshield-4.6.2/envshield/tests/test_cli.py +0 -1569
  51. envshield-4.6.2/envshield/tests/test_config_manager.py +0 -951
  52. envshield-4.6.2/envshield/tests/test_diff_aware_scanning.py +0 -201
  53. envshield-4.6.2/envshield/tests/test_explain_cli.py +0 -306
  54. envshield-4.6.2/envshield/tests/test_git_utils.py +0 -463
  55. envshield-4.6.2/envshield/tests/test_hook_cli.py +0 -187
  56. envshield-4.6.2/envshield/tests/test_schema_diff_cli.py +0 -658
  57. envshield-4.6.2/envshield/tests/test_service_cli.py +0 -549
  58. envshield-4.6.2/envshield/tests/test_undeclared_cli.py +0 -482
  59. envshield-4.6.2/envshield/utils/__init__.py +0 -0
  60. envshield-4.6.2/envshield.egg-info/SOURCES.txt +0 -82
  61. {envshield-4.6.2 → envshield-4.7.0}/LICENSE.md +0 -0
  62. {envshield-4.6.2 → envshield-4.7.0}/envshield/__main__.py +0 -0
  63. {envshield-4.6.2/envshield/core → envshield-4.7.0/envshield/config}/__init__.py +0 -0
  64. {envshield-4.6.2/envshield/parsers → envshield-4.7.0/envshield/core}/__init__.py +0 -0
  65. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/contract_diff.py +0 -0
  66. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/dependency_diff.py +0 -0
  67. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/exceptions.py +0 -0
  68. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/file_updater.py +0 -0
  69. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/generator.py +0 -0
  70. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/hooks_manager.py +0 -0
  71. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/inspector.py +0 -0
  72. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/schema_snapshot.py +0 -0
  73. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/schema_types.py +0 -0
  74. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/service_discovery.py +0 -0
  75. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/service_manager.py +0 -0
  76. {envshield-4.6.2 → envshield-4.7.0}/envshield/core/setup_manager.py +0 -0
  77. {envshield-4.6.2/envshield/tests → envshield-4.7.0/envshield/parsers}/__init__.py +0 -0
  78. {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_base.py +0 -0
  79. {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_dotenv.py +0 -0
  80. {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/_python.py +0 -0
  81. {envshield-4.6.2 → envshield-4.7.0}/envshield/parsers/factory.py +0 -0
  82. {envshield-4.6.2 → envshield-4.7.0}/envshield/state.py +0 -0
  83. {envshield-4.6.2/envshield/tests/core → envshield-4.7.0/envshield/utils}/__init__.py +0 -0
  84. {envshield-4.6.2 → envshield-4.7.0}/envshield/utils/git_utils.py +0 -0
  85. {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/dependency_links.txt +0 -0
  86. {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/entry_points.txt +0 -0
  87. {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/requires.txt +0 -0
  88. {envshield-4.6.2 → envshield-4.7.0}/envshield.egg-info/top_level.txt +0 -0
  89. {envshield-4.6.2 → envshield-4.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envshield
3
- Version: 4.6.2
3
+ Version: 4.7.0
4
4
  Summary: EnvShield: environment variables as a version-controlled configuration contract.
5
5
  Author-email: Rabbil Yasar Sajal <rabbilyasar@gmail.com>
6
6
  License: MIT License
@@ -100,9 +100,11 @@ env.schema.toml
100
100
  ## Quick Start
101
101
 
102
102
  ```bash
103
- pip install envshield
103
+ pipx install envshield
104
104
  ```
105
105
 
106
+ Prefer an existing Python/pip workflow instead? `pip install envshield` works the same way, as an alternative.
107
+
106
108
  In an existing project:
107
109
 
108
110
  ```bash
@@ -299,7 +301,7 @@ jobs:
299
301
  steps:
300
302
  - uses: actions/checkout@v4
301
303
  with: { fetch-depth: 0 }
302
- - run: pip install envshield
304
+ - run: pipx install envshield
303
305
  - run: |
304
306
  BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
305
307
  envshield schema diff "$BASE" HEAD
@@ -353,12 +355,67 @@ envshield check --service api
353
355
 
354
356
  Every command is service-aware. `--service` is optional with exactly one service configured, inferred automatically when you're standing inside a registered service's own directory, and only prompted for interactively when there's more than one and neither applies.
355
357
 
358
+ ### Discovering reads in a shared library (`additional_source_roots`)
359
+
360
+ `undeclared` and `explain` only walk a service's own directory by default. If part of a service's real configuration is read from a shared internal library that lives outside that directory — a common-utilities package imported by several services, say — those reads are invisible to both commands, for every service that imports the library.
361
+
362
+ `additional_source_roots` widens a service's discovery scope to cover extra directories:
363
+
364
+ ```yaml
365
+ services:
366
+ api:
367
+ schema: services/api/env.schema.toml
368
+ additional_source_roots:
369
+ - libs/shared
370
+ ```
371
+
372
+ `undeclared --service api` and `explain --service api` now also look inside `libs/shared` — a variable read there counts toward `api`'s own discovery results exactly as if it lived in `services/api/` itself. The same directory can be listed by more than one service; each one independently sees the reads it contains. A path that doesn't exist yet contributes nothing (no error); a path resolving outside the project is refused, the same protection every other path in `envshield.yml` already has. `scan` is unaffected — it's already repository-wide.
373
+
374
+ ---
375
+
376
+ ## Multiple sources per service (`completeness: union`)
377
+
378
+ By default, every registered source for a service — its `local_file`, each registered deployment manifest — is validated independently against the *whole* schema: `check`/`doctor` expect any one of them to be self-sufficient on its own. Some services genuinely don't work that way: part of the configuration lives in a local config file, the rest is supplied by a deployment manifest. Under the default model, a variable that only ever lives in one of them shows up as "missing" everywhere else, even though nothing is actually wrong.
379
+
380
+ Set `completeness: union` on a service to validate against the *combined* presence of all its registered sources instead — a variable satisfied by any one of them satisfies the service as a whole:
381
+
382
+ ```yaml
383
+ services:
384
+ api:
385
+ schema: services/api/env.schema.toml
386
+ local_file: services/api/config/settings.py
387
+ completeness: union
388
+ manifests:
389
+ - file: docker-compose.yml
390
+ containers:
391
+ api: api
392
+ ```
393
+
394
+ ```toml
395
+ [DB_HOST]
396
+ description = "set in config/settings.py"
397
+
398
+ [FEATURE_MODE]
399
+ description = "set via docker-compose.yml"
400
+ ```
401
+
402
+ With `completeness: union`, `check`/`doctor` stop expecting `config/settings.py` to declare `FEATURE_MODE`, or `docker-compose.yml` to set `DB_HOST` — the schema is satisfied as long as every variable is present *somewhere* among the registered sources. `envshield schema sync` respects this too: it only ever appends a variable to a Python `local_file` that's genuinely missing from every registered source, never one that another source already covers.
403
+
404
+ A few things worth knowing:
405
+
406
+ - **Opt-in, per service.** Unset (the default) means every source must be independently self-sufficient, exactly as before this existed — nothing changes unless you set the key.
407
+ - **Union means presence, not values.** Whether an ordinary variable is satisfied depends only on whether it's present (and valid) somewhere — never on merging, comparing, or picking between its values across sources. A blank or invalid value is still reported, wherever it occurs. **The one deliberate exception** is a `requiredIf` trigger's own value (see next) — deciding whether a condition currently holds has always meant reading one specific value, even before `completeness: union` existed; union only extends *where* that one value may come from, it doesn't turn this into general value merging.
408
+ - **A `requiredIf` condition (see [Conditional requirements](#conditional-requirements-requiredif) above) is resolved against the union too** — if the variable a condition depends on lives in a different source than the one being checked, `completeness: union` still finds it. Sources that agree on the trigger's value resolve normally; if two sources genuinely disagree, EnvShield reports it as an explicit conflict rather than guessing which one wins — registration order is never used as a tiebreaker.
409
+ - **A source that fails to load is always a failure**, even if the sources that did load happen to satisfy the schema between them. `doctor` reports this as two separate signals for a union-mode service — source health (does this file exist and parse) and registered-source completeness (does the union satisfy the schema) — so one can never quietly hide behind the other. `check --json` reflects the same split: each source's own result is still reported individually, plus one `combined` entry per union-mode service carrying the aggregate verdict.
410
+
356
411
  ---
357
412
 
358
413
  ## Supported languages / discovery
359
414
 
360
415
  Source-code discovery (what powers `undeclared` and `explain`'s "used in source" section) is AST-based for **Python** (`os.environ.get`, `os.getenv`, `os.environ[...]`) and pattern-based for **JavaScript/TypeScript** (`process.env.X`, `process.env["X"]`, `import.meta.env.X`, including single-level destructuring). No other language is discovered yet — the schema and validation commands work with any stack, but `undeclared`/`explain` can only see what's read from these two.
361
416
 
417
+ `explain` additionally recognizes Flask's `current_app.config[...]`/`.get(...)` in Python — including the common `from flask import current_app as <alias>` import form — and reports it at **medium confidence**, shown inline as `(medium confidence)`: it's one level of indirection through a config object whose contents could come from anywhere, not a direct environment read. This recognition is deliberately `explain`-only — it never affects `undeclared`'s or `scan`'s pass/fail signal, so a Flask-config read can never quietly satisfy (or break) a completeness check the way a direct `os.environ`/`os.getenv` read does.
418
+
362
419
  ---
363
420
 
364
421
  ## Secret safety
@@ -374,8 +431,8 @@ A supporting check alongside the contract, not the product itself. `secret = tru
374
431
 
375
432
  EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
376
433
 
377
- - **Docker Compose:** `${VAR}` and `${VAR:-default}` are supported. The no-colon `${VAR-default}` form, the `${VAR:?}`/`${VAR:+}` forms, and embedded/multiple interpolation references in one value aren't evaluated yet.
378
- - **Kubernetes:** `secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`), not the referenced Secret/ConfigMap's internal key — name your schema variable after the env var, not the key. `envFrom.prefix` isn't applied yet.
434
+ - **Docker Compose:** `${VAR}`, `${VAR:-default}`, and the no-colon `${VAR-default}` form are all supported. The `${VAR:?}`/`${VAR:+}` forms and embedded/multiple interpolation references in one value aren't evaluated yet.
435
+ - **Kubernetes:** `env[].valueFrom.secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`) — name your schema variable after the env var, not the key — and, when the referenced Secret/ConfigMap is defined in the same manifest, the referenced key is validated to actually exist there. `envFrom.prefix` is applied. `initContainers` are selectable via `--container` but never auto-selected by default.
379
436
  - **Source discovery:** Python is AST-based; JavaScript/TypeScript is pattern-based. No other language is discovered yet.
380
437
  - **.env:** multiline quoted values aren't supported.
381
438
 
@@ -41,9 +41,11 @@ env.schema.toml
41
41
  ## Quick Start
42
42
 
43
43
  ```bash
44
- pip install envshield
44
+ pipx install envshield
45
45
  ```
46
46
 
47
+ Prefer an existing Python/pip workflow instead? `pip install envshield` works the same way, as an alternative.
48
+
47
49
  In an existing project:
48
50
 
49
51
  ```bash
@@ -240,7 +242,7 @@ jobs:
240
242
  steps:
241
243
  - uses: actions/checkout@v4
242
244
  with: { fetch-depth: 0 }
243
- - run: pip install envshield
245
+ - run: pipx install envshield
244
246
  - run: |
245
247
  BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
246
248
  envshield schema diff "$BASE" HEAD
@@ -294,12 +296,67 @@ envshield check --service api
294
296
 
295
297
  Every command is service-aware. `--service` is optional with exactly one service configured, inferred automatically when you're standing inside a registered service's own directory, and only prompted for interactively when there's more than one and neither applies.
296
298
 
299
+ ### Discovering reads in a shared library (`additional_source_roots`)
300
+
301
+ `undeclared` and `explain` only walk a service's own directory by default. If part of a service's real configuration is read from a shared internal library that lives outside that directory — a common-utilities package imported by several services, say — those reads are invisible to both commands, for every service that imports the library.
302
+
303
+ `additional_source_roots` widens a service's discovery scope to cover extra directories:
304
+
305
+ ```yaml
306
+ services:
307
+ api:
308
+ schema: services/api/env.schema.toml
309
+ additional_source_roots:
310
+ - libs/shared
311
+ ```
312
+
313
+ `undeclared --service api` and `explain --service api` now also look inside `libs/shared` — a variable read there counts toward `api`'s own discovery results exactly as if it lived in `services/api/` itself. The same directory can be listed by more than one service; each one independently sees the reads it contains. A path that doesn't exist yet contributes nothing (no error); a path resolving outside the project is refused, the same protection every other path in `envshield.yml` already has. `scan` is unaffected — it's already repository-wide.
314
+
315
+ ---
316
+
317
+ ## Multiple sources per service (`completeness: union`)
318
+
319
+ By default, every registered source for a service — its `local_file`, each registered deployment manifest — is validated independently against the *whole* schema: `check`/`doctor` expect any one of them to be self-sufficient on its own. Some services genuinely don't work that way: part of the configuration lives in a local config file, the rest is supplied by a deployment manifest. Under the default model, a variable that only ever lives in one of them shows up as "missing" everywhere else, even though nothing is actually wrong.
320
+
321
+ Set `completeness: union` on a service to validate against the *combined* presence of all its registered sources instead — a variable satisfied by any one of them satisfies the service as a whole:
322
+
323
+ ```yaml
324
+ services:
325
+ api:
326
+ schema: services/api/env.schema.toml
327
+ local_file: services/api/config/settings.py
328
+ completeness: union
329
+ manifests:
330
+ - file: docker-compose.yml
331
+ containers:
332
+ api: api
333
+ ```
334
+
335
+ ```toml
336
+ [DB_HOST]
337
+ description = "set in config/settings.py"
338
+
339
+ [FEATURE_MODE]
340
+ description = "set via docker-compose.yml"
341
+ ```
342
+
343
+ With `completeness: union`, `check`/`doctor` stop expecting `config/settings.py` to declare `FEATURE_MODE`, or `docker-compose.yml` to set `DB_HOST` — the schema is satisfied as long as every variable is present *somewhere* among the registered sources. `envshield schema sync` respects this too: it only ever appends a variable to a Python `local_file` that's genuinely missing from every registered source, never one that another source already covers.
344
+
345
+ A few things worth knowing:
346
+
347
+ - **Opt-in, per service.** Unset (the default) means every source must be independently self-sufficient, exactly as before this existed — nothing changes unless you set the key.
348
+ - **Union means presence, not values.** Whether an ordinary variable is satisfied depends only on whether it's present (and valid) somewhere — never on merging, comparing, or picking between its values across sources. A blank or invalid value is still reported, wherever it occurs. **The one deliberate exception** is a `requiredIf` trigger's own value (see next) — deciding whether a condition currently holds has always meant reading one specific value, even before `completeness: union` existed; union only extends *where* that one value may come from, it doesn't turn this into general value merging.
349
+ - **A `requiredIf` condition (see [Conditional requirements](#conditional-requirements-requiredif) above) is resolved against the union too** — if the variable a condition depends on lives in a different source than the one being checked, `completeness: union` still finds it. Sources that agree on the trigger's value resolve normally; if two sources genuinely disagree, EnvShield reports it as an explicit conflict rather than guessing which one wins — registration order is never used as a tiebreaker.
350
+ - **A source that fails to load is always a failure**, even if the sources that did load happen to satisfy the schema between them. `doctor` reports this as two separate signals for a union-mode service — source health (does this file exist and parse) and registered-source completeness (does the union satisfy the schema) — so one can never quietly hide behind the other. `check --json` reflects the same split: each source's own result is still reported individually, plus one `combined` entry per union-mode service carrying the aggregate verdict.
351
+
297
352
  ---
298
353
 
299
354
  ## Supported languages / discovery
300
355
 
301
356
  Source-code discovery (what powers `undeclared` and `explain`'s "used in source" section) is AST-based for **Python** (`os.environ.get`, `os.getenv`, `os.environ[...]`) and pattern-based for **JavaScript/TypeScript** (`process.env.X`, `process.env["X"]`, `import.meta.env.X`, including single-level destructuring). No other language is discovered yet — the schema and validation commands work with any stack, but `undeclared`/`explain` can only see what's read from these two.
302
357
 
358
+ `explain` additionally recognizes Flask's `current_app.config[...]`/`.get(...)` in Python — including the common `from flask import current_app as <alias>` import form — and reports it at **medium confidence**, shown inline as `(medium confidence)`: it's one level of indirection through a config object whose contents could come from anywhere, not a direct environment read. This recognition is deliberately `explain`-only — it never affects `undeclared`'s or `scan`'s pass/fail signal, so a Flask-config read can never quietly satisfy (or break) a completeness check the way a direct `os.environ`/`os.getenv` read does.
359
+
303
360
  ---
304
361
 
305
362
  ## Secret safety
@@ -315,8 +372,8 @@ A supporting check alongside the contract, not the product itself. `secret = tru
315
372
 
316
373
  EnvShield is built around the patterns real projects actually use. A few narrower cases aren't handled yet — none of them let a secret leak or let a genuinely missing required variable pass as clean:
317
374
 
318
- - **Docker Compose:** `${VAR}` and `${VAR:-default}` are supported. The no-colon `${VAR-default}` form, the `${VAR:?}`/`${VAR:+}` forms, and embedded/multiple interpolation references in one value aren't evaluated yet.
319
- - **Kubernetes:** `secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`), not the referenced Secret/ConfigMap's internal key — name your schema variable after the env var, not the key. `envFrom.prefix` isn't applied yet.
375
+ - **Docker Compose:** `${VAR}`, `${VAR:-default}`, and the no-colon `${VAR-default}` form are all supported. The `${VAR:?}`/`${VAR:+}` forms and embedded/multiple interpolation references in one value aren't evaluated yet.
376
+ - **Kubernetes:** `env[].valueFrom.secretKeyRef`/`configMapKeyRef` are matched by the container's environment-variable name (`env[].name`) — name your schema variable after the env var, not the key — and, when the referenced Secret/ConfigMap is defined in the same manifest, the referenced key is validated to actually exist there. `envFrom.prefix` is applied. `initContainers` are selectable via `--container` but never auto-selected by default.
320
377
  - **Source discovery:** Python is AST-based; JavaScript/TypeScript is pattern-based. No other language is discovered yet.
321
378
  - **.env:** multiline quoted values aren't supported.
322
379
 
@@ -0,0 +1 @@
1
+ __version__ = "4.7.0"
@@ -30,7 +30,11 @@ from .core import (
30
30
  service_manager,
31
31
  setup_manager,
32
32
  )
33
- from .core.exceptions import EnvShieldException, SchemaNotFoundError
33
+ from .core.exceptions import (
34
+ EnvShieldException,
35
+ SchemaNotFoundError,
36
+ VariableNotFoundError,
37
+ )
34
38
  from .utils import git_utils
35
39
 
36
40
 
@@ -134,6 +138,52 @@ def _print_service_header(targets: List[str], target: str) -> None:
134
138
  console.print(f"\n[bold underline]── {target} ──[/bold underline]")
135
139
 
136
140
 
141
+ def _print_union_completeness_summary(
142
+ service_name: str,
143
+ union_result: "schema_manager.UnionCompletenessResult",
144
+ source_errors: List[str],
145
+ ) -> None:
146
+ """
147
+ `check`'s Rich-output counterpart to each source's own individual diff
148
+ table above it -- explains *why* a target with one or more
149
+ individually-incomplete-looking source tables can still exit 0 (or
150
+ still fail, for a reason none of those tables show on their own, like
151
+ a cross-source requiredIf ambiguity or a source that failed to load).
152
+ """
153
+ console.print(
154
+ f"\n[bold]Registered-source completeness ({service_name}, completeness: union)[/bold]"
155
+ )
156
+ for error in source_errors:
157
+ console.print(f" [bold red]✗[/bold red] Source error: {error}")
158
+ if union_result.is_clean and not source_errors:
159
+ console.print(
160
+ " [bold green]✓ Satisfied by the union of registered sources.[/bold green]"
161
+ )
162
+ return
163
+ if union_result.missing:
164
+ console.print(
165
+ " [red]Missing from every registered source:[/red] "
166
+ f"{', '.join(sorted(union_result.missing))}"
167
+ )
168
+ if union_result.blank:
169
+ console.print(
170
+ " [red]Blank everywhere it's declared:[/red] "
171
+ f"{', '.join(sorted(union_result.blank))}"
172
+ )
173
+ if union_result.invalid:
174
+ details = "; ".join(
175
+ f"{k} ({v})" for k, v in sorted(union_result.invalid.items())
176
+ )
177
+ console.print(f" [red]Invalid:[/red] {details}")
178
+ if union_result.unresolved:
179
+ console.print(
180
+ " [yellow]Cannot confirm (a registered source has an unresolved "
181
+ f"external reference):[/yellow] {', '.join(sorted(union_result.unresolved))}"
182
+ )
183
+ for key, reason in sorted(union_result.ambiguous_requiredif.items()):
184
+ console.print(f" [red]Ambiguous ({key}):[/red] {reason}")
185
+
186
+
137
187
  # --- Commands ---
138
188
  @app.command()
139
189
  def init(
@@ -438,9 +488,18 @@ def check(
438
488
 
439
489
  had_error = False
440
490
  results = []
491
+ combined: Dict[str, Any] = {}
441
492
  for target in targets:
442
493
  if not json_output:
443
494
  _print_service_header(targets, target)
495
+ # completeness: union (BL-030) is opt-in per service and only
496
+ # applies to the service's own default source set -- an explicit
497
+ # `file` argument means the user asked for exactly that one file,
498
+ # which is unaffected by (and skips) union evaluation entirely,
499
+ # same as it already skips registered manifests below.
500
+ is_union = (
501
+ not file and config_manager.get_service_completeness_mode(target) == "union"
502
+ )
444
503
  try:
445
504
  resolved_file = (
446
505
  file or config_manager.get_env_paths(service_name=target)["local_file"]
@@ -450,12 +509,13 @@ def check(
450
509
  resolved_file, service_name=target, container=container
451
510
  )
452
511
  results.append(result)
453
- if not result["clean"]:
512
+ if not is_union and not result["clean"]:
454
513
  had_error = True
455
514
  elif not schema_manager.check_schema(
456
515
  resolved_file, service_name=target, container=container
457
516
  ):
458
- had_error = True
517
+ if not is_union:
518
+ had_error = True
459
519
 
460
520
  # An explicit file argument means the user asked for exactly
461
521
  # that file, and nothing else -- only pile on registered
@@ -473,14 +533,42 @@ def check(
473
533
  container=manifest_container,
474
534
  )
475
535
  results.append(result)
476
- if not result["clean"]:
536
+ if not is_union and not result["clean"]:
477
537
  had_error = True
478
538
  elif not schema_manager.check_schema(
479
539
  manifest["path"],
480
540
  service_name=target,
481
541
  container=manifest_container,
482
542
  ):
483
- had_error = True
543
+ if not is_union:
544
+ had_error = True
545
+
546
+ # completeness: union's own aggregate verdict, computed
547
+ # separately from (and never replacing) the per-source diffs
548
+ # above -- those keep reporting each source's own individual
549
+ # gaps for visibility; this decides whether the TARGET as a
550
+ # whole passes. A source that failed to load here is always a
551
+ # failure, independent of whether the sources that DID load
552
+ # happen to satisfy the schema between them (BL-030's explicit
553
+ # source-health requirement).
554
+ if is_union:
555
+ schema = config_manager.load_schema(service_name=target)
556
+ sources, source_errors = schema_manager.load_union_sources(
557
+ target, container=container
558
+ )
559
+ union_result = schema_manager.evaluate_union_completeness(
560
+ schema, sources
561
+ )
562
+ if source_errors or not union_result.is_clean:
563
+ had_error = True
564
+ if json_output:
565
+ combined[target] = schema_manager.union_completeness_result_to_dict(
566
+ union_result, source_errors=source_errors
567
+ )
568
+ else:
569
+ _print_union_completeness_summary(
570
+ target, union_result, source_errors
571
+ )
484
572
  except EnvShieldException as e:
485
573
  if json_output:
486
574
  results.append({"service": target, "clean": False, "error": str(e)})
@@ -489,7 +577,10 @@ def check(
489
577
  had_error = True
490
578
 
491
579
  if json_output:
492
- print(json.dumps({"success": not had_error, "results": results}, indent=2))
580
+ payload: Dict[str, Any] = {"success": not had_error, "results": results}
581
+ if combined:
582
+ payload["combined"] = combined
583
+ print(json.dumps(payload, indent=2))
493
584
 
494
585
  if had_error:
495
586
  raise typer.Exit(code=1)
@@ -572,6 +663,100 @@ _EXPLAIN_REQUIREDNESS_LABEL = {
572
663
  }
573
664
 
574
665
 
666
+ def _render_source_usages(
667
+ source_usages: List["explain.discovery.DiscoveredVariableUsage"],
668
+ ) -> None:
669
+ """Shared between the declared-variable report and the undeclared one -- the exact same section, so the two commands' output stays visually consistent."""
670
+ console.print("\n[bold]Used in source[/bold]")
671
+ if source_usages:
672
+ for usage in source_usages:
673
+ line = f" {usage.file_path}:{usage.line}"
674
+ if usage.confidence != "high":
675
+ # BL-113: a Flask current_app.config[...] read is one level
676
+ # of indirection through an object whose contents came from
677
+ # somewhere unspecified -- never as certain as a direct
678
+ # os.environ/os.getenv read, and this must stay visible at
679
+ # the point of reading, not just in --json's confidence field.
680
+ line += f" [dim]({usage.confidence} confidence)[/dim]"
681
+ console.print(line)
682
+ else:
683
+ console.print(" None discovered")
684
+ console.print(
685
+ " [dim]EnvShield only recognizes os.environ/os.getenv/process.env-style "
686
+ "reads in Python and JS/TS -- this doesn't prove the variable is unused.[/dim]"
687
+ )
688
+
689
+
690
+ def _render_manifest_references(
691
+ manifest_references: List["explain.ManifestReference"], variable: str
692
+ ) -> None:
693
+ """Shared between the declared-variable report and the undeclared one."""
694
+ console.print("\n[bold]Deployment[/bold]")
695
+ if not manifest_references:
696
+ console.print(" No deployment manifest registered for this service.")
697
+ return
698
+
699
+ declared = [r for r in manifest_references if r.status == "declared"]
700
+ not_declared = [r for r in manifest_references if r.status == "not_declared"]
701
+ unresolved = [r for r in manifest_references if r.status == "unresolved"]
702
+ errored = [r for r in manifest_references if r.status == "error"]
703
+ if declared:
704
+ for ref in declared:
705
+ console.print(f" {ref.path}")
706
+ if ref.container:
707
+ console.print(f" container: {ref.container}")
708
+ elif not_declared:
709
+ # Only claim "not found" when at least one manifest positively
710
+ # doesn't declare it -- printing this alongside "Cannot confirm"
711
+ # below (when every non-declared reference is actually unresolved)
712
+ # would read as EnvShield confidently asserting absence in the
713
+ # same breath it admits it can't tell.
714
+ checked = ", ".join(r.path for r in manifest_references)
715
+ console.print(
716
+ " Not found in any registered deployment manifest "
717
+ f"(checked: {checked}) -- this doesn't prove it isn't deployed elsewhere."
718
+ )
719
+ if unresolved:
720
+ console.print(
721
+ f" [yellow]Cannot confirm for {len(unresolved)} manifest(s): "
722
+ f"{', '.join(r.path for r in unresolved)} -- references an external "
723
+ f"ConfigMap/Secret not included in the manifest; '{variable}' "
724
+ "may be supplied from there.[/yellow]"
725
+ )
726
+ if errored:
727
+ console.print(
728
+ f" [dim]Could not check {len(errored)} manifest(s): "
729
+ f"{', '.join(r.path for r in errored)}[/dim]"
730
+ )
731
+
732
+
733
+ def _render_undeclared_explain_report(
734
+ report: "explain.UndeclaredVariableReport",
735
+ ) -> None:
736
+ """
737
+ 'explain' on a variable that isn't in the schema yet -- degrades
738
+ gracefully instead of only erroring, reusing the exact same
739
+ source-usage/manifest-reference rendering a declared variable's report
740
+ uses, so a developer investigating something 'undeclared'/'scan' just
741
+ found gets the same evidence, framed as "not declared yet" rather than
742
+ a bare error.
743
+ """
744
+ console.print(f"\n[bold cyan]{report.variable}[/bold cyan]")
745
+ console.print("─" * max(len(report.variable), 8))
746
+ console.print(
747
+ f"\n[yellow]Not declared in the schema for service '{report.service}'.[/yellow]"
748
+ )
749
+
750
+ _render_source_usages(report.source_usages)
751
+ _render_manifest_references(report.manifest_references, report.variable)
752
+
753
+ if report.source_usages:
754
+ console.print(
755
+ "\n[dim]Add it to the schema once you know what it should require -- "
756
+ "see the file/line above for where it's read.[/dim]"
757
+ )
758
+
759
+
575
760
  def _render_explain_report(report: "explain.ExplainReport") -> None:
576
761
  console.print(f"\n[bold cyan]{report.variable}[/bold cyan]")
577
762
  console.print("─" * max(len(report.variable), 8))
@@ -607,16 +792,7 @@ def _render_explain_report(report: "explain.ExplainReport") -> None:
607
792
  " [dim](could not determine whether this is inherited via extends)[/dim]"
608
793
  )
609
794
 
610
- console.print("\n[bold]Used in source[/bold]")
611
- if report.source_usages:
612
- for usage in report.source_usages:
613
- console.print(f" {usage.file_path}:{usage.line}")
614
- else:
615
- console.print(" None discovered")
616
- console.print(
617
- " [dim]EnvShield only recognizes os.environ/os.getenv/process.env-style "
618
- "reads in Python and JS/TS -- this doesn't prove the variable is unused.[/dim]"
619
- )
795
+ _render_source_usages(report.source_usages)
620
796
 
621
797
  if report.required_by:
622
798
  console.print("\n[bold]Required by[/bold]")
@@ -627,45 +803,7 @@ def _render_explain_report(report: "explain.ExplainReport") -> None:
627
803
  f' when {report.variable} == "{condition.get("equals", "true")}"'
628
804
  )
629
805
 
630
- console.print("\n[bold]Deployment[/bold]")
631
- if not report.manifest_references:
632
- console.print(" No deployment manifest registered for this service.")
633
- else:
634
- declared = [r for r in report.manifest_references if r.status == "declared"]
635
- not_declared = [
636
- r for r in report.manifest_references if r.status == "not_declared"
637
- ]
638
- unresolved = [r for r in report.manifest_references if r.status == "unresolved"]
639
- errored = [r for r in report.manifest_references if r.status == "error"]
640
- if declared:
641
- for ref in declared:
642
- console.print(f" {ref.path}")
643
- if ref.container:
644
- console.print(f" container: {ref.container}")
645
- elif not_declared:
646
- # Only claim "not found" when at least one manifest positively
647
- # doesn't declare it -- printing this alongside "Cannot
648
- # confirm" below (when every non-declared reference is
649
- # actually unresolved) would read as EnvShield confidently
650
- # asserting absence in the same breath it admits it can't
651
- # tell.
652
- checked = ", ".join(r.path for r in report.manifest_references)
653
- console.print(
654
- " Not found in any registered deployment manifest "
655
- f"(checked: {checked}) -- this doesn't prove it isn't deployed elsewhere."
656
- )
657
- if unresolved:
658
- console.print(
659
- f" [yellow]Cannot confirm for {len(unresolved)} manifest(s): "
660
- f"{', '.join(r.path for r in unresolved)} -- references an external "
661
- f"ConfigMap/Secret not included in the manifest; '{report.variable}' "
662
- "may be supplied from there.[/yellow]"
663
- )
664
- if errored:
665
- console.print(
666
- f" [dim]Could not check {len(errored)} manifest(s): "
667
- f"{', '.join(r.path for r in errored)}[/dim]"
668
- )
806
+ _render_manifest_references(report.manifest_references, report.variable)
669
807
 
670
808
 
671
809
  @app.command(name="explain")
@@ -703,6 +841,16 @@ def explain_command(
703
841
 
704
842
  try:
705
843
  report = explain.build_report(variable, resolved_service)
844
+ except VariableNotFoundError:
845
+ # Degrade gracefully instead of only erroring -- exactly the
846
+ # workflow moment a developer reaches for 'explain' most: a
847
+ # variable 'undeclared'/'scan' just found, not yet in the schema.
848
+ undeclared_report = explain.build_undeclared_report(variable, resolved_service)
849
+ if json_output:
850
+ print(json.dumps(undeclared_report.to_dict(), indent=2))
851
+ else:
852
+ _render_undeclared_explain_report(undeclared_report)
853
+ raise typer.Exit(code=1)
706
854
  except EnvShieldException as e:
707
855
  if json_output:
708
856
  print(