nac-cli 0.1.0__tar.gz → 0.2.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 (72) hide show
  1. {nac_cli-0.1.0 → nac_cli-0.2.0}/.github/workflows/release.yml +2 -2
  2. {nac_cli-0.1.0 → nac_cli-0.2.0}/.github/workflows/test.yml +3 -3
  3. nac_cli-0.2.0/CHANGELOG.md +8 -0
  4. nac_cli-0.2.0/PKG-INFO +106 -0
  5. nac_cli-0.2.0/README.md +66 -0
  6. {nac_cli-0.1.0 → nac_cli-0.2.0}/pyproject.toml +1 -1
  7. nac_cli-0.2.0/release-notes.md +3 -0
  8. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/setup.py +18 -7
  9. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/test.py +18 -9
  10. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/validate.py +16 -6
  11. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/exceptions.py +4 -0
  12. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/tools.py +55 -1
  13. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_cli_commands.py +98 -7
  14. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_cli_setup.py +52 -0
  15. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_cli_versions.py +8 -8
  16. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_tools.py +42 -2
  17. {nac_cli-0.1.0 → nac_cli-0.2.0}/uv.lock +136 -201
  18. nac_cli-0.1.0/CHANGELOG.md +0 -3
  19. nac_cli-0.1.0/PKG-INFO +0 -386
  20. nac_cli-0.1.0/README.md +0 -346
  21. nac_cli-0.1.0/release-notes.md +0 -2
  22. {nac_cli-0.1.0 → nac_cli-0.2.0}/.github/dependabot.yml +0 -0
  23. {nac_cli-0.1.0 → nac_cli-0.2.0}/.gitignore +0 -0
  24. {nac_cli-0.1.0 → nac_cli-0.2.0}/.pre-commit-config.yaml +0 -0
  25. {nac_cli-0.1.0 → nac_cli-0.2.0}/LICENSE +0 -0
  26. {nac_cli-0.1.0 → nac_cli-0.2.0}/scripts/license_headers.py +0 -0
  27. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/__init__.py +0 -0
  28. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/__main__.py +0 -0
  29. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/bootstrap.py +0 -0
  30. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/__init__.py +0 -0
  31. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/apply.py +0 -0
  32. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/destroy.py +0 -0
  33. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/init.py +0 -0
  34. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/main.py +0 -0
  35. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/options.py +0 -0
  36. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/passthrough.py +0 -0
  37. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/plan.py +0 -0
  38. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/cli/versions.py +0 -0
  39. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/config.py +0 -0
  40. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/manifest.py +0 -0
  41. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/output.py +0 -0
  42. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/runner.py +0 -0
  43. {nac_cli-0.1.0 → nac_cli-0.2.0}/src/nac/terraform.py +0 -0
  44. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/aci-lean/defaults.yaml +0 -0
  45. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/aci-lean/nac.yaml +0 -0
  46. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/filters-absent/nac.yaml +0 -0
  47. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/filters-present/nac.yaml +0 -0
  48. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/filters-present/tests/filters/.gitkeep +0 -0
  49. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/invalid-syntax/nac.yaml +0 -0
  50. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/iosxe-lean/nac.yaml +0 -0
  51. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/minimal/nac.yaml +0 -0
  52. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/nxos-full/nac.yaml +0 -0
  53. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/nxos-lean/nac.yaml +0 -0
  54. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/templates-absent/nac.yaml +0 -0
  55. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/templates-present/nac.yaml +0 -0
  56. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/fixtures/nac-yaml/templates-present/tests/templates/.gitkeep +0 -0
  57. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/fixtures/local_only/.terraform.lock.hcl +0 -0
  58. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/fixtures/local_only/broken/main.tf +0 -0
  59. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/fixtures/local_only/main.tf +0 -0
  60. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/fixtures/validate_test/.schema.yaml +0 -0
  61. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/fixtures/validate_test/data/main.yaml +0 -0
  62. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/fixtures/validate_test/templates/test.robot +0 -0
  63. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/test_streaming.py +0 -0
  64. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/integration/test_validate_test.py +0 -0
  65. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/conftest.py +0 -0
  66. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_bootstrap.py +0 -0
  67. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_config.py +0 -0
  68. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_manifest.py +0 -0
  69. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_output.py +0 -0
  70. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_placeholder.py +0 -0
  71. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_runner.py +0 -0
  72. {nac_cli-0.1.0 → nac_cli-0.2.0}/tests/unit/test_terraform.py +0 -0
@@ -20,7 +20,7 @@ jobs:
20
20
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
21
 
22
22
  - name: Install uv
23
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
23
+ uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
24
24
 
25
25
  - name: Check tag matches project version
26
26
  env:
@@ -102,7 +102,7 @@ jobs:
102
102
  id-token: write # Required for PyPI trusted publishing
103
103
  steps:
104
104
  - name: Install uv
105
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
105
+ uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
106
106
 
107
107
  - name: Download distributions
108
108
  uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -21,7 +21,7 @@ jobs:
21
21
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
22
 
23
23
  - name: Install uv
24
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
24
+ uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
25
25
  with:
26
26
  enable-cache: true
27
27
 
@@ -49,7 +49,7 @@ jobs:
49
49
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
50
50
 
51
51
  - name: Install uv
52
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
52
+ uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
53
53
  with:
54
54
  enable-cache: true
55
55
 
@@ -89,7 +89,7 @@ jobs:
89
89
  python-version: ${{ matrix.python }}
90
90
 
91
91
  - name: Install uv
92
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
92
+ uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
93
93
  with:
94
94
  enable-cache: true
95
95
 
@@ -0,0 +1,8 @@
1
+ ## 0.2.0
2
+
3
+ - Only require `uv` when `nac-validate`/`nac-test` aren't installed locally in a satisfying version: `nac setup` no longer fails without `uv` if both tools resolve locally, and `nac validate`/`nac test` fail with a clear message (instead of a missing-executable error) when `uvx` would be needed but `uv` isn't on `PATH`
4
+ - Enable `nac-validate`'s and `nac-test`'s `--artifacts` option (schema, rules and test templates packaged with the module or with bundles) by default via `NAC_VALIDATE_ARTIFACTS`/`NAC_TEST_ARTIFACTS`; versions without the option ignore it, and an existing value of the env var or a passed-through `--no-artifacts` takes precedence
5
+
6
+ ## 0.1.0
7
+
8
+ - Initial release
nac_cli-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.5
2
+ Name: nac-cli
3
+ Version: 0.2.0
4
+ Summary: An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test for Network-as-Code workflows.
5
+ Project-URL: homepage, https://github.com/netascode/nac
6
+ Project-URL: repository, https://github.com/netascode/nac
7
+ Project-URL: documentation, https://github.com/netascode/nac
8
+ Author-email: Daniel Schmidt <danischm@cisco.com>
9
+ Maintainer-email: Daniel Schmidt <danischm@cisco.com>
10
+ License: MPL-2.0
11
+ License-File: LICENSE
12
+ Keywords: automation,cli,nac,network-as-code,opentofu,terraform
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development :: Build Tools
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: httpx>=0.28
26
+ Requires-Dist: packaging>=24
27
+ Requires-Dist: platformdirs>=4.3
28
+ Requires-Dist: pydantic>=2.9
29
+ Requires-Dist: ruamel-yaml>=0.19.1
30
+ Requires-Dist: typer>=0.25.1
31
+ Provides-Extra: dev
32
+ Requires-Dist: bandit[toml]>=1.9.1; extra == 'dev'
33
+ Requires-Dist: pre-commit>=4.4.0; extra == 'dev'
34
+ Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
35
+ Requires-Dist: pytest-mock>=3.15.1; extra == 'dev'
36
+ Requires-Dist: pytest>=9.0.1; extra == 'dev'
37
+ Requires-Dist: ruff>=0.14.5; extra == 'dev'
38
+ Requires-Dist: ty>=0.0.78; extra == 'dev'
39
+ Description-Content-Type: text/markdown
40
+
41
+ [![Tests](https://github.com/netascode/nac/actions/workflows/test.yml/badge.svg)](https://github.com/netascode/nac/actions/workflows/test.yml)
42
+ ![Python Support](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-informational "Python Support: 3.11, 3.12, 3.13, 3.14")
43
+
44
+ # nac
45
+
46
+ An umbrella CLI for Network-as-Code workflows, driven by a single `nac.yaml`
47
+ config file.
48
+
49
+ Every Network-as-Code repository runs the same workflow, regardless of the
50
+ architecture: validate the data model, plan, apply and test. Each step uses a
51
+ different tool with its own options: `terraform` or `tofu`, `nac-validate` and
52
+ `nac-test`. `nac` is a single CLI for the whole workflow. The repository is
53
+ described once in `nac.yaml`, and the same commands work on a laptop and in a
54
+ CI/CD pipeline:
55
+
56
+ ```bash
57
+ nac setup # verify prerequisites and report resolved tool versions
58
+ nac init # terraform/tofu init
59
+ nac validate # nac-validate against the configured data paths
60
+ nac plan # terraform/tofu plan -> plan.tfplan
61
+ nac apply # apply the saved plan (or interactively, without one)
62
+ nac test # nac-test against the configured data/templates/output
63
+ nac destroy # destroy the managed infrastructure
64
+ ```
65
+
66
+ Full documentation: https://netascode.cisco.com/docs/tools/nac/overview
67
+
68
+ ## How It Works
69
+
70
+ - **One configuration file:** `nac.yaml` defines where the data model,
71
+ schema, rules and test templates are located, which Terraform engine to use
72
+ and which environment variables are required. Every field has a default, so
73
+ a repository following the `data/` and `tests/` conventions works without a
74
+ `nac.yaml` at all.
75
+ - **Managed toolchain:** `nac-validate` and `nac-test` are used from a local
76
+ installation if one satisfies the configured version, or run via `uvx`
77
+ otherwise. If neither `terraform` nor `tofu` is installed, `nac setup` can
78
+ install OpenTofu.
79
+ - **Tested versions:** a Network-as-Code Terraform module can declare the
80
+ `nac-validate` and `nac-test` versions it was tested with in
81
+ `nac/manifest.yaml`. `nac` then uses a compatible version by default and
82
+ warns about versions outside of the tested range.
83
+ - **Pass-through:** arguments not known to `nac` are forwarded to the wrapped
84
+ tool, and the exit code of the wrapped tool is returned.
85
+
86
+ ## Installation
87
+
88
+ `nac` is published on PyPI as **`nac-cli`**; the command it installs is `nac`.
89
+ Install it with [uv](https://docs.astral.sh/uv/getting-started/installation/):
90
+
91
+ ```bash
92
+ uv tool install nac-cli
93
+ ```
94
+
95
+ > [!WARNING]
96
+ > Don't `pip install nac` or run `uvx nac` -- that is a different, unrelated
97
+ > package.
98
+
99
+ See the [installation docs](https://netascode.cisco.com/docs/tools/nac/installation/)
100
+ for prerequisites, and [usage](https://netascode.cisco.com/docs/tools/nac/usage/)
101
+ and [configuration](https://netascode.cisco.com/docs/tools/nac/configuration/)
102
+ for the full command and `nac.yaml` reference.
103
+
104
+ ## License
105
+
106
+ [MPL-2.0](LICENSE)
@@ -0,0 +1,66 @@
1
+ [![Tests](https://github.com/netascode/nac/actions/workflows/test.yml/badge.svg)](https://github.com/netascode/nac/actions/workflows/test.yml)
2
+ ![Python Support](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-informational "Python Support: 3.11, 3.12, 3.13, 3.14")
3
+
4
+ # nac
5
+
6
+ An umbrella CLI for Network-as-Code workflows, driven by a single `nac.yaml`
7
+ config file.
8
+
9
+ Every Network-as-Code repository runs the same workflow, regardless of the
10
+ architecture: validate the data model, plan, apply and test. Each step uses a
11
+ different tool with its own options: `terraform` or `tofu`, `nac-validate` and
12
+ `nac-test`. `nac` is a single CLI for the whole workflow. The repository is
13
+ described once in `nac.yaml`, and the same commands work on a laptop and in a
14
+ CI/CD pipeline:
15
+
16
+ ```bash
17
+ nac setup # verify prerequisites and report resolved tool versions
18
+ nac init # terraform/tofu init
19
+ nac validate # nac-validate against the configured data paths
20
+ nac plan # terraform/tofu plan -> plan.tfplan
21
+ nac apply # apply the saved plan (or interactively, without one)
22
+ nac test # nac-test against the configured data/templates/output
23
+ nac destroy # destroy the managed infrastructure
24
+ ```
25
+
26
+ Full documentation: https://netascode.cisco.com/docs/tools/nac/overview
27
+
28
+ ## How It Works
29
+
30
+ - **One configuration file:** `nac.yaml` defines where the data model,
31
+ schema, rules and test templates are located, which Terraform engine to use
32
+ and which environment variables are required. Every field has a default, so
33
+ a repository following the `data/` and `tests/` conventions works without a
34
+ `nac.yaml` at all.
35
+ - **Managed toolchain:** `nac-validate` and `nac-test` are used from a local
36
+ installation if one satisfies the configured version, or run via `uvx`
37
+ otherwise. If neither `terraform` nor `tofu` is installed, `nac setup` can
38
+ install OpenTofu.
39
+ - **Tested versions:** a Network-as-Code Terraform module can declare the
40
+ `nac-validate` and `nac-test` versions it was tested with in
41
+ `nac/manifest.yaml`. `nac` then uses a compatible version by default and
42
+ warns about versions outside of the tested range.
43
+ - **Pass-through:** arguments not known to `nac` are forwarded to the wrapped
44
+ tool, and the exit code of the wrapped tool is returned.
45
+
46
+ ## Installation
47
+
48
+ `nac` is published on PyPI as **`nac-cli`**; the command it installs is `nac`.
49
+ Install it with [uv](https://docs.astral.sh/uv/getting-started/installation/):
50
+
51
+ ```bash
52
+ uv tool install nac-cli
53
+ ```
54
+
55
+ > [!WARNING]
56
+ > Don't `pip install nac` or run `uvx nac` -- that is a different, unrelated
57
+ > package.
58
+
59
+ See the [installation docs](https://netascode.cisco.com/docs/tools/nac/installation/)
60
+ for prerequisites, and [usage](https://netascode.cisco.com/docs/tools/nac/usage/)
61
+ and [configuration](https://netascode.cisco.com/docs/tools/nac/configuration/)
62
+ for the full command and `nac.yaml` reference.
63
+
64
+ ## License
65
+
66
+ [MPL-2.0](LICENSE)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nac-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "An umbrella CLI wrapping terraform/tofu, nac-validate, and nac-test for Network-as-Code workflows."
5
5
  license = { text = "MPL-2.0" }
6
6
  readme = "README.md"
@@ -0,0 +1,3 @@
1
+
2
+ - Only require `uv` when `nac-validate`/`nac-test` aren't installed locally in a satisfying version: `nac setup` no longer fails without `uv` if both tools resolve locally, and `nac validate`/`nac test` fail with a clear message (instead of a missing-executable error) when `uvx` would be needed but `uv` isn't on `PATH`
3
+ - Enable `nac-validate`'s and `nac-test`'s `--artifacts` option (schema, rules and test templates packaged with the module or with bundles) by default via `NAC_VALIDATE_ARTIFACTS`/`NAC_TEST_ARTIFACTS`; versions without the option ignore it, and an existing value of the env var or a passed-through `--no-artifacts` takes precedence
@@ -25,10 +25,6 @@ from .versions import emit_warnings, get_manifest, tool_warnings
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
- UV_NOT_FOUND = (
29
- "`uv` not found on PATH -- install it: "
30
- "https://docs.astral.sh/uv/getting-started/installation/"
31
- )
32
28
  TENV_NOT_FOUND = (
33
29
  "`tenv` not found on PATH -- required because `tools.terraform.version` "
34
30
  "is set. Install tenv: https://github.com/tofuutils/tenv"
@@ -89,9 +85,7 @@ def setup(ctx: typer.Context, yes: Yes = False, prewarm: Prewarm = False) -> Non
89
85
  cfg = get_config(ctx)
90
86
  no_color = get_no_color(ctx)
91
87
 
92
- if shutil.which("uv") is None:
93
- fail(UV_NOT_FOUND)
94
- lines: list[str] = ["uv: found"]
88
+ lines: list[str] = []
95
89
 
96
90
  if cfg.tools.terraform.version is not None and shutil.which("tenv") is None:
97
91
  fail(TENV_NOT_FOUND)
@@ -151,6 +145,23 @@ def setup(ctx: typer.Context, yes: Yes = False, prewarm: Prewarm = False) -> Non
151
145
  test_line, test_source, test_constraint = _tool_summary_line(
152
146
  "nac-test", cfg.tools.nac_test
153
147
  )
148
+ uv_errors: list[str] = []
149
+ if validate_source.mode == "uvx":
150
+ uv_errors.append(
151
+ tools.uv_not_found_message(
152
+ "nac-validate", validate_source, validate_constraint
153
+ )
154
+ )
155
+ if test_source.mode == "uvx":
156
+ uv_errors.append(
157
+ tools.uv_not_found_message("nac-test", test_source, test_constraint)
158
+ )
159
+ if tools.uv_available():
160
+ lines.insert(0, "uv: found")
161
+ elif uv_errors:
162
+ fail("\n".join(uv_errors))
163
+ else:
164
+ lines.insert(0, "uv: not found (not needed, all tools resolve locally)")
154
165
  lines.append(validate_line)
155
166
  lines.append(test_line)
156
167
  warnings = [
@@ -6,9 +6,9 @@
6
6
  import typer
7
7
 
8
8
  from nac import runner, tools
9
- from nac.output import apply_no_color_env
9
+ from nac.exceptions import UvNotFoundError
10
10
 
11
- from .main import app, get_config, get_no_color
11
+ from .main import app, fail, get_config, get_no_color
12
12
  from .options import Artifacts
13
13
  from .passthrough import (
14
14
  PASSTHROUGH_CONTEXT_SETTINGS,
@@ -34,15 +34,24 @@ def test_(ctx: typer.Context, artifacts: Artifacts = False) -> None:
34
34
  log_path = cfg.working_dir / "test.txt" if artifacts else None
35
35
  manifest = get_manifest(ctx, cfg)
36
36
 
37
- if wants_help(ctx):
38
- show_help_and_exit(
39
- ctx,
37
+ try:
38
+ tool_argv = (
40
39
  tools.build_help_argv(
41
40
  "nac-test",
42
41
  tools.tool_constraint("nac-test", cfg.tools.nac_test, manifest),
43
- ),
42
+ )
43
+ if wants_help(ctx)
44
+ else tools.build_test_argv(cfg, manifest) + ctx.args
45
+ )
46
+ except UvNotFoundError as exc:
47
+ fail(str(exc))
48
+
49
+ if wants_help(ctx):
50
+ show_help_and_exit(
51
+ ctx,
52
+ tool_argv,
44
53
  cwd=cfg.working_dir,
45
- env=apply_no_color_env(no_color),
54
+ env=tools.build_tool_env("nac-test", no_color=no_color),
46
55
  no_color=no_color,
47
56
  tool_label="nac-test",
48
57
  usage_as="nac test",
@@ -52,9 +61,9 @@ def test_(ctx: typer.Context, artifacts: Artifacts = False) -> None:
52
61
  log_written = emit_warnings(warnings, no_color=no_color, log_path=log_path)
53
62
 
54
63
  code = runner.run_streaming(
55
- tools.build_test_argv(cfg, manifest) + ctx.args,
64
+ tool_argv,
56
65
  cwd=cfg.working_dir,
57
- env=apply_no_color_env(no_color),
66
+ env=tools.build_tool_env("nac-test", no_color=no_color),
58
67
  log_path=log_path,
59
68
  append=log_written,
60
69
  no_color=no_color,
@@ -8,9 +8,10 @@ import logging
8
8
  import typer
9
9
 
10
10
  from nac import runner, terraform, tools
11
- from nac.output import GREEN, apply_no_color_env, color, echo_summary
11
+ from nac.exceptions import UvNotFoundError
12
+ from nac.output import GREEN, color, echo_summary
12
13
 
13
- from .main import app, get_config, get_no_color, resolve_engine_or_exit
14
+ from .main import app, fail, get_config, get_no_color, resolve_engine_or_exit
14
15
  from .options import Artifacts
15
16
  from .passthrough import (
16
17
  PASSTHROUGH_CONTEXT_SETTINGS,
@@ -36,12 +37,21 @@ def validate(ctx: typer.Context, artifacts: Artifacts = False) -> None:
36
37
  manifest = get_manifest(ctx, cfg)
37
38
  constraint = tools.tool_constraint("nac-validate", cfg.tools.nac_validate, manifest)
38
39
 
40
+ try:
41
+ tool_argv = (
42
+ tools.build_help_argv("nac-validate", constraint)
43
+ if wants_help(ctx)
44
+ else tools.build_validate_argv(cfg, manifest) + ctx.args
45
+ )
46
+ except UvNotFoundError as exc:
47
+ fail(str(exc))
48
+
39
49
  if wants_help(ctx):
40
50
  show_help_and_exit(
41
51
  ctx,
42
- tools.build_help_argv("nac-validate", constraint),
52
+ tool_argv,
43
53
  cwd=cfg.working_dir,
44
- env=apply_no_color_env(no_color),
54
+ env=tools.build_tool_env("nac-validate", no_color=no_color),
45
55
  no_color=no_color,
46
56
  tool_label="nac-validate",
47
57
  usage_as="nac validate",
@@ -79,9 +89,9 @@ def validate(ctx: typer.Context, artifacts: Artifacts = False) -> None:
79
89
  )
80
90
 
81
91
  code = runner.run_streaming(
82
- tools.build_validate_argv(cfg, manifest) + ctx.args,
92
+ tool_argv,
83
93
  cwd=cfg.working_dir,
84
- env=apply_no_color_env(no_color),
94
+ env=tools.build_tool_env("nac-validate", no_color=no_color),
85
95
  log_path=log_path,
86
96
  append=log_written,
87
97
  no_color=no_color,
@@ -32,3 +32,7 @@ class BootstrapDeclinedError(BootstrapError):
32
32
 
33
33
  class ChecksumMismatchError(BootstrapError):
34
34
  """The downloaded archive's SHA-256 didn't match the published checksum."""
35
+
36
+
37
+ class UvNotFoundError(NacError):
38
+ """A wrapped tool needs `uvx` (no usable local install) but uv isn't on PATH."""
@@ -12,14 +12,17 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
12
12
  from packaging.version import InvalidVersion, Version
13
13
 
14
14
  from nac.config import NacConfig, resolve_test_data, resolve_validate_data
15
+ from nac.exceptions import UvNotFoundError
15
16
  from nac.manifest import Manifest, compatible_range
16
- from nac.output import format_command
17
+ from nac.output import apply_no_color_env, format_command
17
18
 
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
21
  _SPECIFIER_PREFIXES = ("==", "!=", ">=", "<=", "~=", ">", "<")
21
22
  _VERSION_RE = re.compile(r"\d+(?:\.\d+)+(?:[a-zA-Z0-9.\-+]*)?")
22
23
 
24
+ UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/"
25
+
23
26
 
24
27
  def _pin_suffix(version: str) -> str:
25
28
  v = version.strip()
@@ -117,13 +120,42 @@ def resolve_tool_source(
117
120
  return ToolSource(mode="uvx", path=None, version=version)
118
121
 
119
122
 
123
+ def uv_available() -> bool:
124
+ """Whether uv (which provides `uvx`) is on PATH."""
125
+ return shutil.which("uv") is not None
126
+
127
+
128
+ def uv_not_found_message(
129
+ tool: Literal["nac-validate", "nac-test"],
130
+ source: ToolSource,
131
+ constraint: str | None,
132
+ ) -> str:
133
+ """Explain why `tool` needs uv and how to fix it (install uv or the tool)."""
134
+ if source.version is not None:
135
+ reason = f"local {tool} v{source.version} does not satisfy {constraint}"
136
+ else:
137
+ reason = f"{tool} is not installed locally"
138
+ wanted = f"{tool}{_pin_suffix(constraint)}" if constraint is not None else tool
139
+ return (
140
+ f"{reason}, so it would be run via `uvx`, but `uv` was not found on "
141
+ f"PATH -- install uv ({UV_INSTALL_URL}) or install {wanted} yourself."
142
+ )
143
+
144
+
120
145
  def _tool_prefix(
121
146
  tool: Literal["nac-validate", "nac-test"], constraint: str | None
122
147
  ) -> list[str]:
148
+ """Command prefix for `tool`: the local binary, else `uvx [--from ...]`.
149
+
150
+ Raises `UvNotFoundError` if `uvx` would be needed but isn't on PATH, so
151
+ `nac` works without uv as long as suitable local installs exist.
152
+ """
123
153
  source = resolve_tool_source(tool, constraint)
124
154
  if source.mode == "local":
125
155
  assert source.path is not None
126
156
  return [source.path]
157
+ if not uv_available():
158
+ raise UvNotFoundError(uv_not_found_message(tool, source, constraint))
127
159
  argv = ["uvx"]
128
160
  if constraint is not None:
129
161
  argv += ["--from", f"{tool}{_pin_suffix(constraint)}"]
@@ -156,6 +188,28 @@ def build_test_argv(cfg: NacConfig, manifest: Manifest | None = None) -> list[st
156
188
  return argv
157
189
 
158
190
 
191
+ # Env vars turning on bundle/module artifact discovery in nac-validate/nac-test
192
+ # (`--artifacts`). Set via env rather than the CLI flag so that tool versions
193
+ # predating the option simply ignore it.
194
+ _ARTIFACTS_ENV = {
195
+ "nac-validate": "NAC_VALIDATE_ARTIFACTS",
196
+ "nac-test": "NAC_TEST_ARTIFACTS",
197
+ }
198
+
199
+
200
+ def build_tool_env(
201
+ tool: Literal["nac-validate", "nac-test"], *, no_color: bool
202
+ ) -> dict[str, str]:
203
+ """Subprocess env for `tool`, with artifacts enabled by default.
204
+
205
+ A value already set in the environment wins, and a passed-through
206
+ `--no-artifacts` overrides the env var, so users can still opt out.
207
+ """
208
+ env = apply_no_color_env(no_color)
209
+ env.setdefault(_ARTIFACTS_ENV[tool], "true")
210
+ return env
211
+
212
+
159
213
  def build_prewarm_argv(
160
214
  tool: Literal["nac-validate", "nac-test"], version: str | None
161
215
  ) -> list[str]:
@@ -41,7 +41,9 @@ def _tofu_on_path(mocker):
41
41
  """Make `tofu` resolve as the engine everywhere via a shared shutil.which."""
42
42
  mocker.patch(
43
43
  "shutil.which",
44
- side_effect=_which({"tofu": "/usr/bin/tofu", "terraform": None}),
44
+ side_effect=_which(
45
+ {"tofu": "/usr/bin/tofu", "terraform": None, "uv": "/usr/bin/uv"}
46
+ ),
45
47
  )
46
48
 
47
49
 
@@ -108,7 +110,7 @@ def test_init_propagates_nonzero_exit_code(tmp_path, mocker, _tofu_on_path):
108
110
  @pytest.mark.unit
109
111
  def test_init_fails_cleanly_when_no_engine_available(tmp_path, mocker):
110
112
  config_path = _write_config(tmp_path, "")
111
- mocker.patch("shutil.which", return_value=None)
113
+ mocker.patch("shutil.which", side_effect=_which({"uv": "/usr/bin/uv"}))
112
114
  run_streaming_mock = mocker.patch("nac.cli.init.runner.run_streaming")
113
115
 
114
116
  result = cli.invoke(app, ["--config", str(config_path), "init"])
@@ -125,7 +127,7 @@ def test_init_fails_cleanly_when_no_engine_available(tmp_path, mocker):
125
127
  @pytest.mark.unit
126
128
  def test_validate_without_render_target_skips_render_step(tmp_path, mocker):
127
129
  config_path = _write_config(tmp_path, "")
128
- mocker.patch("shutil.which", return_value=None)
130
+ mocker.patch("shutil.which", side_effect=_which({"uv": "/usr/bin/uv"}))
129
131
  run_streaming_mock = mocker.patch(
130
132
  "nac.cli.validate.runner.run_streaming", return_value=0
131
133
  )
@@ -139,6 +141,42 @@ def test_validate_without_render_target_skips_render_step(tmp_path, mocker):
139
141
  assert "nac-validate" in argv
140
142
 
141
143
 
144
+ @pytest.mark.unit
145
+ def test_validate_without_uv_uses_local_install(tmp_path, mocker):
146
+ config_path = _write_config(tmp_path, "")
147
+ mocker.patch(
148
+ "shutil.which",
149
+ side_effect=_which({"nac-validate": "/usr/bin/nac-validate"}),
150
+ )
151
+ mocker.patch("nac.tools._local_tool_version", return_value="1.2.3")
152
+ run_streaming_mock = mocker.patch(
153
+ "nac.cli.validate.runner.run_streaming", return_value=0
154
+ )
155
+
156
+ result = cli.invoke(app, ["--config", str(config_path), "validate"])
157
+
158
+ assert result.exit_code == 0
159
+ argv = run_streaming_mock.call_args_list[0].args[0]
160
+ assert argv[0] == "/usr/bin/nac-validate"
161
+
162
+
163
+ @pytest.mark.unit
164
+ def test_validate_without_uv_or_local_install_fails_before_render(tmp_path, mocker):
165
+ config_path = _write_config(
166
+ tmp_path,
167
+ "render:\n target: module.example\n output: rendered/\n",
168
+ )
169
+ mocker.patch("shutil.which", side_effect=_which({"tofu": "/usr/bin/tofu"}))
170
+ run_streaming_mock = mocker.patch("nac.cli.validate.runner.run_streaming")
171
+
172
+ result = cli.invoke(app, ["--config", str(config_path), "validate"])
173
+
174
+ assert result.exit_code == 1
175
+ assert "nac-validate is not installed locally" in result.output
176
+ assert "`uv` was not found on PATH" in result.output
177
+ run_streaming_mock.assert_not_called()
178
+
179
+
142
180
  @pytest.mark.unit
143
181
  def test_validate_with_render_target_runs_render_then_validate(
144
182
  tmp_path, mocker, _tofu_on_path
@@ -575,7 +613,7 @@ def test_destroy_propagates_nonzero_exit_code(tmp_path, mocker, _tofu_on_path):
575
613
  @pytest.mark.unit
576
614
  def test_destroy_fails_cleanly_when_no_engine_available(tmp_path, mocker):
577
615
  config_path = _write_config(tmp_path, "")
578
- mocker.patch("shutil.which", return_value=None)
616
+ mocker.patch("shutil.which", side_effect=_which({"uv": "/usr/bin/uv"}))
579
617
  run_streaming_mock = mocker.patch("nac.cli.destroy.runner.run_streaming")
580
618
 
581
619
  result = cli.invoke(app, ["--config", str(config_path), "destroy"])
@@ -592,7 +630,7 @@ def test_destroy_fails_cleanly_when_no_engine_available(tmp_path, mocker):
592
630
  @pytest.mark.unit
593
631
  def test_test_streams_test_argv_and_propagates_exit_code(tmp_path, mocker):
594
632
  config_path = _write_config(tmp_path, "")
595
- mocker.patch("shutil.which", return_value=None)
633
+ mocker.patch("shutil.which", side_effect=_which({"uv": "/usr/bin/uv"}))
596
634
  run_streaming_mock = mocker.patch(
597
635
  "nac.cli.test.runner.run_streaming", return_value=0
598
636
  )
@@ -606,6 +644,19 @@ def test_test_streams_test_argv_and_propagates_exit_code(tmp_path, mocker):
606
644
  assert run_streaming_mock.call_args_list[0].kwargs.get("log_path") is None
607
645
 
608
646
 
647
+ @pytest.mark.unit
648
+ def test_test_without_uv_or_local_install_fails_cleanly(tmp_path, mocker):
649
+ config_path = _write_config(tmp_path, "")
650
+ mocker.patch("shutil.which", side_effect=_which({}))
651
+ run_streaming_mock = mocker.patch("nac.cli.test.runner.run_streaming")
652
+
653
+ result = cli.invoke(app, ["--config", str(config_path), "test"])
654
+
655
+ assert result.exit_code == 1
656
+ assert "nac-test is not installed locally" in result.output
657
+ run_streaming_mock.assert_not_called()
658
+
659
+
609
660
  @pytest.mark.unit
610
661
  def test_test_with_artifacts_writes_test_txt(tmp_path, mocker):
611
662
  config_path = _write_config(tmp_path, "")
@@ -654,7 +705,7 @@ def test_validate_extra_args_are_appended(tmp_path, mocker):
654
705
  @pytest.mark.unit
655
706
  def test_validate_help_shows_own_and_tool_help(tmp_path, mocker):
656
707
  config_path = _write_config(tmp_path, "")
657
- mocker.patch("shutil.which", return_value=None)
708
+ mocker.patch("shutil.which", side_effect=_which({"uv": "/usr/bin/uv"}))
658
709
  run_mock = mocker.patch(
659
710
  "nac.cli.passthrough.subprocess.run",
660
711
  return_value=subprocess.CompletedProcess(
@@ -747,7 +798,7 @@ def test_test_extra_args_are_appended(tmp_path, mocker):
747
798
  @pytest.mark.unit
748
799
  def test_test_help_shows_own_and_tool_help(tmp_path, mocker):
749
800
  config_path = _write_config(tmp_path, "")
750
- mocker.patch("shutil.which", return_value=None)
801
+ mocker.patch("shutil.which", side_effect=_which({"uv": "/usr/bin/uv"}))
751
802
  run_mock = mocker.patch(
752
803
  "nac.cli.passthrough.subprocess.run",
753
804
  return_value=subprocess.CompletedProcess(
@@ -943,3 +994,43 @@ def test_run_shows_nac_as_program_name_under_alias(mocker, capsys):
943
994
  out = strip_ansi(capsys.readouterr().out)
944
995
  assert "Usage: nac [OPTIONS]" in out
945
996
  assert "nac-cli" not in out
997
+
998
+
999
+ @pytest.mark.unit
1000
+ @pytest.mark.parametrize(
1001
+ ("command", "env_var"),
1002
+ [("validate", "NAC_VALIDATE_ARTIFACTS"), ("test", "NAC_TEST_ARTIFACTS")],
1003
+ )
1004
+ def test_tool_artifacts_enabled_by_default(
1005
+ tmp_path, mocker, monkeypatch, command, env_var
1006
+ ):
1007
+ monkeypatch.delenv(env_var, raising=False)
1008
+ config_path = _write_config(tmp_path, "")
1009
+ run_streaming_mock = mocker.patch(
1010
+ f"nac.cli.{command}.runner.run_streaming", return_value=0
1011
+ )
1012
+
1013
+ result = cli.invoke(app, ["--config", str(config_path), command])
1014
+
1015
+ assert result.exit_code == 0
1016
+ assert run_streaming_mock.call_args.kwargs["env"][env_var] == "true"
1017
+
1018
+
1019
+ @pytest.mark.unit
1020
+ @pytest.mark.parametrize(
1021
+ ("command", "env_var"),
1022
+ [("validate", "NAC_VALIDATE_ARTIFACTS"), ("test", "NAC_TEST_ARTIFACTS")],
1023
+ )
1024
+ def test_tool_artifacts_env_override_is_kept(
1025
+ tmp_path, mocker, monkeypatch, command, env_var
1026
+ ):
1027
+ monkeypatch.setenv(env_var, "false")
1028
+ config_path = _write_config(tmp_path, "")
1029
+ run_streaming_mock = mocker.patch(
1030
+ f"nac.cli.{command}.runner.run_streaming", return_value=0
1031
+ )
1032
+
1033
+ result = cli.invoke(app, ["--config", str(config_path), command])
1034
+
1035
+ assert result.exit_code == 0
1036
+ assert run_streaming_mock.call_args.kwargs["env"][env_var] == "false"