panorama-super-cli 0.2.1__tar.gz → 0.2.3__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 (95) hide show
  1. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/docs.yml +4 -4
  2. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/lint.yml +4 -4
  3. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/release.yml +2 -2
  4. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/workflows/test.yml +2 -2
  5. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/CHANGELOG.md +33 -0
  6. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/PKG-INFO +1 -1
  7. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/output-formats.md +19 -0
  8. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/safety.md +10 -0
  9. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/reference/cli.md +8 -6
  10. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/_version.py +1 -1
  11. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/_plan.py +27 -2
  12. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/app.py +37 -5
  13. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/dedup_cmds.py +5 -3
  14. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/name_cmds.py +12 -5
  15. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/changeset.py +53 -0
  16. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/dedup.py +14 -13
  17. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/naming.py +15 -1
  18. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/source.py +47 -2
  19. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/pyproject.toml +1 -1
  20. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/skills/panorama-super-cli/SKILL.md +16 -2
  21. panorama_super_cli-0.2.3/tests/test_changeset.py +172 -0
  22. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_cli.py +95 -0
  23. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_dedup.py +19 -0
  24. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_naming.py +19 -0
  25. panorama_super_cli-0.2.3/tests/test_source.py +62 -0
  26. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/uv.lock +577 -577
  27. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/CODEOWNERS +0 -0
  28. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  29. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.github/dependabot.yml +0 -0
  32. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.gitignore +0 -0
  33. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.pre-commit-config.yaml +0 -0
  34. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/.python-version +0 -0
  35. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/AGENTS.md +0 -0
  36. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/CLAUDE.md +0 -0
  37. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/LICENSE +0 -0
  38. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/README.md +0 -0
  39. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/contributing/branching.md +0 -0
  40. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/contributing/development.md +0 -0
  41. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/contributing/release-process.md +0 -0
  42. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/getting-started/concepts.md +0 -0
  43. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/getting-started/first-run.md +0 -0
  44. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/getting-started/install.md +0 -0
  45. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/duplicates-and-merging.md +0 -0
  46. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/finding-objects.md +0 -0
  47. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/live-vs-offline.md +0 -0
  48. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/naming.md +0 -0
  49. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/references-and-audit.md +0 -0
  50. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/guides/using-with-ai-agents.md +0 -0
  51. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/index.md +0 -0
  52. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/reference/config.md +0 -0
  53. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/docs/reference/exit-codes.md +0 -0
  54. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/justfile +0 -0
  55. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/mkdocs.yml +0 -0
  56. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/__init__.py +0 -0
  57. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/__main__.py +0 -0
  58. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/__init__.py +0 -0
  59. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/auth_cmds.py +0 -0
  60. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/find_cmds.py +0 -0
  61. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/profile_cmds.py +0 -0
  62. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/refs_cmds.py +0 -0
  63. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/cli/runtime.py +0 -0
  64. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/config/__init__.py +0 -0
  65. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/config/loader.py +0 -0
  66. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/config/models.py +0 -0
  67. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/__init__.py +0 -0
  68. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/apply_live.py +0 -0
  69. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/apply_xml.py +0 -0
  70. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/models.py +0 -0
  71. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/normalize.py +0 -0
  72. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/parse.py +0 -0
  73. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/refs.py +0 -0
  74. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/resolve.py +0 -0
  75. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/core/setcmd.py +0 -0
  76. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/output/__init__.py +0 -0
  77. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/output/errors.py +0 -0
  78. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/psc/output/format.py +0 -0
  79. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/scripts/sync_agents_md.py +0 -0
  80. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/__init__.py +0 -0
  81. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/conftest.py +0 -0
  82. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/fixtures/nested-device-groups.xml +0 -0
  83. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/fixtures/panorama-config.xml +0 -0
  84. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_apply_xml.py +0 -0
  85. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_auth.py +0 -0
  86. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_cli_auth.py +0 -0
  87. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_hardening.py +0 -0
  88. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_live_apply.py +0 -0
  89. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_nested_dg.py +0 -0
  90. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_normalize.py +0 -0
  91. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_output.py +0 -0
  92. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_parse.py +0 -0
  93. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_refs.py +0 -0
  94. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_resolve.py +0 -0
  95. {panorama_super_cli-0.2.1 → panorama_super_cli-0.2.3}/tests/test_setcmd.py +0 -0
@@ -23,9 +23,9 @@ jobs:
23
23
  build:
24
24
  runs-on: ubuntu-latest
25
25
  steps:
26
- - uses: actions/checkout@v4
26
+ - uses: actions/checkout@v6
27
27
  - name: Install uv
28
- uses: astral-sh/setup-uv@v3
28
+ uses: astral-sh/setup-uv@v7
29
29
  with:
30
30
  enable-cache: true
31
31
  - name: Install Python
@@ -36,7 +36,7 @@ jobs:
36
36
  run: uv run mkdocs build --strict
37
37
  - name: Upload Pages artifact
38
38
  if: github.ref_type == 'tag'
39
- uses: actions/upload-pages-artifact@v3
39
+ uses: actions/upload-pages-artifact@v5
40
40
  with:
41
41
  path: site
42
42
 
@@ -49,4 +49,4 @@ jobs:
49
49
  url: ${{ steps.deployment.outputs.page_url }}
50
50
  steps:
51
51
  - id: deployment
52
- uses: actions/deploy-pages@v4
52
+ uses: actions/deploy-pages@v5
@@ -9,9 +9,9 @@ jobs:
9
9
  lint:
10
10
  runs-on: ubuntu-latest
11
11
  steps:
12
- - uses: actions/checkout@v4
12
+ - uses: actions/checkout@v6
13
13
  - name: Install uv
14
- uses: astral-sh/setup-uv@v3
14
+ uses: astral-sh/setup-uv@v7
15
15
  with:
16
16
  enable-cache: true
17
17
  - name: Install Python
@@ -28,9 +28,9 @@ jobs:
28
28
  agents-md-fresh:
29
29
  runs-on: ubuntu-latest
30
30
  steps:
31
- - uses: actions/checkout@v4
31
+ - uses: actions/checkout@v6
32
32
  - name: Install uv
33
- uses: astral-sh/setup-uv@v3
33
+ uses: astral-sh/setup-uv@v7
34
34
  with:
35
35
  enable-cache: true
36
36
  - name: Install Python
@@ -25,7 +25,7 @@ jobs:
25
25
  # no-environment form.
26
26
  steps:
27
27
  - name: Checkout (full history for changelog notes)
28
- uses: actions/checkout@v4
28
+ uses: actions/checkout@v6
29
29
  with:
30
30
  fetch-depth: 0
31
31
 
@@ -40,7 +40,7 @@ jobs:
40
40
  echo "Tag $GITHUB_REF_NAME ($GITHUB_SHA) is on main."
41
41
 
42
42
  - name: Install uv
43
- uses: astral-sh/setup-uv@v3
43
+ uses: astral-sh/setup-uv@v7
44
44
  with:
45
45
  enable-cache: true
46
46
 
@@ -14,9 +14,9 @@ jobs:
14
14
  os: [ubuntu-latest, macos-latest]
15
15
  python: ["3.12", "3.13"]
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@v6
18
18
  - name: Install uv
19
- uses: astral-sh/setup-uv@v3
19
+ uses: astral-sh/setup-uv@v7
20
20
  with:
21
21
  enable-cache: true
22
22
  - name: Install Python
@@ -7,6 +7,39 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.2.3 — 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - **`-of` / `--output-format xml|set` for the offline `--apply --out` artifact.**
15
+ Mutating commands (`dedup merge`, `name rename`, `name apply`) can now write the
16
+ rewritten config file as the equivalent PAN-OS `set` script instead of XML.
17
+ `xml` (the rewritten, `load config`-able config) stays the default, so existing
18
+ invocations are unchanged; `set` writes the creates/deletes/repoints that
19
+ achieve the same change, which is easier to read and to paste into a config
20
+ session or `load config partial`. This is distinct from `-o set`, which renders
21
+ the dry-run plan to stdout. The blocker hard-gate and repoint-before-delete
22
+ ordering apply to both formats; a blocked plan writes no file. (#37)
23
+
24
+ ## v0.2.2 — 2026-06-04
25
+
26
+ ### Fixed
27
+
28
+ - **Repoint-before-delete no longer skipped silently.** A merge or rename whose
29
+ only safe repoint is a reference the appliers can't express as a flat member
30
+ rewrite — a NAT source/destination *translation* field, or a rule edit with no
31
+ rulebase — is now refused with a `blocker` (exit 6) on both the offline and
32
+ live paths, naming the object and the unmappable referrer. Previously the edit
33
+ was dropped while the delete/rename still ran, leaving a dangling reference.
34
+ Advisory unmappable edits with no accompanying teardown still pass with a
35
+ warning. (#28)
36
+ - **`psc` with no arguments (and unknown commands) print help, not a traceback.**
37
+ Typer 0.16+ vendors its own Click, so the exceptions the entry point raised
38
+ were not the `click.*` the wrapper caught and escaped as a traceback ending in
39
+ `NoArgsIsHelpError`. The wrapper now handles both the vendored and the real
40
+ Click hierarchies, resolving the module defensively so psc still imports on
41
+ older Typer. No-args/`--help` exit 0; usage errors exit 2. (#31)
42
+
10
43
  ## v0.2.1 — 2026-06-04
11
44
 
12
45
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
6
6
  Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
@@ -50,6 +50,25 @@ Member-list edits become `delete` + `set` because PAN-OS `set` on a member field
50
50
  can't safely render (NAT translation paths) appear as `# REVIEW` comments; the
51
51
  structured (`json`) plan still carries them.
52
52
 
53
+ ### `-o set` (stdout) vs `--output-format set` (the `--out` file)
54
+
55
+ These are two different knobs and it's worth keeping them straight:
56
+
57
+ - **`-o set`** controls what a command prints to **stdout** — use it to read or
58
+ pipe the plan during a dry-run.
59
+ - **`-of` / `--output-format`** (offline mutating commands only) controls the
60
+ format of the **`--apply --out` file artifact**: `xml` (default) rewrites the
61
+ whole config to load with `load config`, while `set` writes the same PAN-OS
62
+ `set` script shown above to that file — easier to read and to paste into a
63
+ config session or `load config partial`.
64
+
65
+ ```console
66
+ psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out plan.set -of set
67
+ ```
68
+
69
+ `-of` only shapes the file and is ignored without `--apply`; on a live profile
70
+ there is no file artifact, so it has no effect there.
71
+
53
72
  ## Errors
54
73
 
55
74
  Errors are emitted as a stable JSON envelope:
@@ -46,6 +46,16 @@ psc -c panorama.xml dedup merge --keep a --remove b --apply --out fixed.xml
46
46
  diff <(xmllint --format panorama.xml) <(xmllint --format fixed.xml)
47
47
  ```
48
48
 
49
+ By default the `--out` file is the rewritten config XML. Pass
50
+ `-of/--output-format set` to instead write the equivalent PAN-OS `set` script
51
+ (the creates/deletes/repoints that achieve the same change) — easier to read and
52
+ to paste into a config session. The blocker gate and repoint-before-delete
53
+ ordering apply to both formats identically; a blocked plan writes no file.
54
+
55
+ ```console
56
+ psc -c panorama.xml dedup merge --keep a --remove b --apply --out plan.set -of set
57
+ ```
58
+
49
59
  ## Live writes
50
60
 
51
61
  Live `--apply` pushes the plan to Panorama's **candidate** config over the XML
@@ -17,9 +17,11 @@ These are **context** options — pass them *before* the subcommand:
17
17
  | `--debug` | Verbose structured logs on stderr. |
18
18
  | `--version` | Print version and exit. |
19
19
 
20
- Write-execution options (`--apply`, `--out`) belong to the individual mutating
21
- commands and are passed *after* the command. `--apply` is the only path to a
22
- write; offline it requires `--out PATH` (a new file), while live it pushes to
20
+ Write-execution options (`--apply`, `--out`, `-of/--output-format`) belong to
21
+ the individual mutating commands and are passed *after* the command. `--apply`
22
+ is the only path to a write; offline it requires `--out PATH` (a new file) and
23
+ `-of xml|set` chooses what that file holds (default `xml`), while live it pushes
24
+ to
23
25
  Panorama's candidate config and never commits. See
24
26
  [Writes and safety](../guides/safety.md).
25
27
 
@@ -42,7 +44,7 @@ psc dedup addresses
42
44
  psc dedup services
43
45
  psc dedup merge --keep NAME --remove NAME [--location LOC]
44
46
  [--keep-location LOC] [--remove-location LOC]
45
- [--allow-value-change] [--apply] [--out PATH]
47
+ [--allow-value-change] [--apply] [--out PATH] [-of xml|set]
46
48
  ```
47
49
 
48
50
  Find duplicates; merge one object into another, repointing all references. See
@@ -63,8 +65,8 @@ Where-used, recursive unused, and dangling-reference audit. See
63
65
 
64
66
  ```
65
67
  psc name lint [--all]
66
- psc name rename --object OLD --to NEW [--kind KIND] [--location LOC] [--apply] [--out PATH]
67
- psc name apply --object NAME [--location LOC] [--apply] [--out PATH]
68
+ psc name rename --object OLD --to NEW [--kind KIND] [--location LOC] [--apply] [--out PATH] [-of xml|set]
69
+ psc name apply --object NAME [--location LOC] [--apply] [--out PATH] [-of xml|set]
68
70
  ```
69
71
 
70
72
  Opt-in naming-template lint and reference-aware rename. See
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.2.1"
7
+ __version__ = "0.2.3"
@@ -7,14 +7,39 @@ refused, dry-run is the default, and `--apply` is the only path to a write.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import typer
11
+
10
12
  from psc.cli.runtime import Runtime
11
13
  from psc.core.changeset import ChangeSet
12
14
  from psc.core.setcmd import render_changeset
15
+ from psc.core.source import ConfigFormat
13
16
  from psc.output.errors import ErrorType, PscError
14
17
  from psc.output.format import OutputFormat, render
15
18
 
19
+ # Shared across every mutating command so the `--out` artifact toggle reads and
20
+ # behaves identically wherever `complete()` is used (dedup merge, name rename,
21
+ # name apply). Default `xml` keeps existing scripts byte-for-byte compatible.
22
+ OUT_FORMAT_OPTION = typer.Option(
23
+ ConfigFormat.XML,
24
+ "--output-format",
25
+ "-of",
26
+ help=(
27
+ "Format of the offline --out artifact. 'xml' (default) rewrites the whole "
28
+ "config to load with `load config`; 'set' writes the equivalent PAN-OS set "
29
+ "script (the creates/deletes/repoints) — easier to read and to paste into a "
30
+ "config session. Only affects --out; ignored without --apply."
31
+ ),
32
+ )
33
+
16
34
 
17
- def complete(rt: Runtime, cs: ChangeSet, *, apply: bool, out_path: str | None) -> None:
35
+ def complete(
36
+ rt: Runtime,
37
+ cs: ChangeSet,
38
+ *,
39
+ apply: bool,
40
+ out_path: str | None,
41
+ out_format: ConfigFormat = ConfigFormat.XML,
42
+ ) -> None:
18
43
  """Render `cs` in the chosen format, then apply it iff `apply`.
19
44
 
20
45
  A blocked plan raises `CONFLICT` (exit 6) before any write. The structured
@@ -43,7 +68,7 @@ def complete(rt: Runtime, cs: ChangeSet, *, apply: bool, out_path: str | None) -
43
68
  return
44
69
 
45
70
  if apply:
46
- result = rt.source().apply(cs, out_path=out_path)
71
+ result = rt.source().apply(cs, out_path=out_path, out_format=out_format)
47
72
  rt.stderr.print(
48
73
  f"[green]applied[/green] {result.ops} operation(s)"
49
74
  + (f" → {result.out_path}" if result.out_path else "")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from types import ModuleType
6
7
 
7
8
  import click
8
9
  import typer
@@ -15,6 +16,28 @@ from psc.config.loader import load_config
15
16
  from psc.output.errors import PscError
16
17
  from psc.output.format import OutputFormat
17
18
 
19
+
20
+ def _click_exception_module() -> ModuleType:
21
+ """The Click `exceptions` module whose flavour matches what `app(...)` raises.
22
+
23
+ Typer >=0.16 vendors its own Click under `typer._click`, so the exceptions
24
+ `app(standalone_mode=False)` raises are not subclasses of the real `click.*`
25
+ (issue #31). Older Typer has no `typer._click` at all — importing it at
26
+ module top level would crash psc on *import*, breaking every command, which
27
+ is strictly worse than the bug we're fixing. Resolve defensively: prefer the
28
+ vendored module, fall back to the real Click (which older Typer raises).
29
+ """
30
+ try:
31
+ from typer._click import exceptions as vendored # noqa: PLC0415 — conditional
32
+ except ImportError:
33
+ from click import exceptions as real # noqa: PLC0415 — fallback for older Typer
34
+
35
+ return real
36
+ return vendored
37
+
38
+
39
+ _typer_click_exc = _click_exception_module()
40
+
18
41
  app = typer.Typer(
19
42
  add_completion=True,
20
43
  no_args_is_help=True,
@@ -125,18 +148,27 @@ def _emit_error(err: PscError) -> None:
125
148
 
126
149
 
127
150
  def main() -> None:
151
+ # Typer 0.26 vendors its own Click (`typer._click`), so the exceptions
152
+ # `app(standalone_mode=False)` raises are NOT subclasses of the real
153
+ # `click.*`. Catch both flavours, or a no-args/`--help`/bad-command run
154
+ # escapes uncaught and prints a traceback instead of help (#31).
128
155
  try:
129
156
  app(standalone_mode=False)
130
157
  except PscError as err:
131
158
  _emit_error(err)
132
159
  raise SystemExit(err.exit_code) from None
133
- except click.UsageError as err:
134
- err.show()
135
- raise SystemExit(2) from None
136
- except (click.exceptions.Abort, KeyboardInterrupt):
160
+ except (KeyboardInterrupt, click.exceptions.Abort, _typer_click_exc.Abort):
137
161
  Console(stderr=True).print("aborted")
138
162
  raise SystemExit(130) from None
139
- except click.exceptions.Exit as err:
163
+ except (click.exceptions.Exit, _typer_click_exc.Exit) as err:
164
+ raise SystemExit(err.exit_code) from None
165
+ except (click.ClickException, _typer_click_exc.ClickException) as err:
166
+ # `no_args_is_help` (and `--help`) already printed the help text; the
167
+ # raise is just a stop signal, so exit 0 rather than rendering it as an
168
+ # error. Genuine usage errors still print and exit non-zero.
169
+ if err.__class__.__name__ == "NoArgsIsHelpError":
170
+ raise SystemExit(0) from None
171
+ err.show()
140
172
  raise SystemExit(err.exit_code) from None
141
173
 
142
174
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from psc.cli._plan import complete
7
+ from psc.cli._plan import OUT_FORMAT_OPTION, complete
8
8
  from psc.cli.runtime import Runtime
9
9
  from psc.core.dedup import (
10
10
  DuplicateGroup,
@@ -14,6 +14,7 @@ from psc.core.dedup import (
14
14
  plan_merge,
15
15
  )
16
16
  from psc.core.refs import ReferenceGraph
17
+ from psc.core.source import ConfigFormat
17
18
  from psc.output.errors import ErrorType, PscError
18
19
  from psc.output.format import render
19
20
 
@@ -71,8 +72,9 @@ def merge(
71
72
  ),
72
73
  apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
73
74
  out: str | None = typer.Option(
74
- None, "--out", help="Offline: write the rewritten config XML here."
75
+ None, "--out", help="Offline: write the rewritten config here (see --output-format)."
75
76
  ),
77
+ output_format: ConfigFormat = OUT_FORMAT_OPTION,
76
78
  ) -> None:
77
79
  """Collapse one address object into another, repointing every reference.
78
80
 
@@ -91,4 +93,4 @@ def merge(
91
93
  drop=ObjectRef(name=remove, location=remove_location or default_loc),
92
94
  allow_value_change=allow_value_change,
93
95
  )
94
- complete(rt, cs, apply=apply, out_path=out)
96
+ complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from psc.cli._plan import complete
7
+ from psc.cli._plan import OUT_FORMAT_OPTION, complete
8
8
  from psc.cli.runtime import Runtime
9
9
  from psc.core.changeset import ObjectKind
10
10
  from psc.core.naming import lint as lint_engine
11
11
  from psc.core.naming import plan_rename
12
12
  from psc.core.refs import ReferenceGraph
13
+ from psc.core.source import ConfigFormat
13
14
  from psc.output.errors import ErrorType, PscError
14
15
  from psc.output.format import render
15
16
 
@@ -49,7 +50,10 @@ def rename(
49
50
  kind: ObjectKind = typer.Option(ObjectKind.ADDRESS, "--kind"),
50
51
  location: str | None = typer.Option(None, "--location"),
51
52
  apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
52
- out: str | None = typer.Option(None, "--out", help="Offline: write rewritten config XML here."),
53
+ out: str | None = typer.Option(
54
+ None, "--out", help="Offline: write rewritten config here (see --output-format)."
55
+ ),
56
+ output_format: ConfigFormat = OUT_FORMAT_OPTION,
53
57
  ) -> None:
54
58
  """Rename one object, repointing every reference (refuses on shadow collisions)."""
55
59
  rt: Runtime = ctx.obj
@@ -57,7 +61,7 @@ def rename(
57
61
  snap = rt.snapshot()
58
62
  graph = ReferenceGraph.build(snap)
59
63
  cs = plan_rename(snap, graph, kind=kind, location_name=loc, old_name=object_name, new_name=to)
60
- complete(rt, cs, apply=apply, out_path=out)
64
+ complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
61
65
 
62
66
 
63
67
  @app.command("apply")
@@ -66,7 +70,10 @@ def apply_scheme(
66
70
  object_name: str = typer.Option(..., "--object", help="Object to rename to its scheme name."),
67
71
  location: str | None = typer.Option(None, "--location"),
68
72
  apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
69
- out: str | None = typer.Option(None, "--out", help="Offline: write rewritten config XML here."),
73
+ out: str | None = typer.Option(
74
+ None, "--out", help="Offline: write rewritten config here (see --output-format)."
75
+ ),
76
+ output_format: ConfigFormat = OUT_FORMAT_OPTION,
70
77
  ) -> None:
71
78
  """Rename one object to the name the configured scheme implies for its value."""
72
79
  rt: Runtime = ctx.obj
@@ -98,4 +105,4 @@ def apply_scheme(
98
105
  cs = plan_rename(
99
106
  snap, graph, kind=kind, location_name=loc, old_name=object_name, new_name=suggested
100
107
  )
101
- complete(rt, cs, apply=apply, out_path=out)
108
+ complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
@@ -125,3 +125,56 @@ class ChangeSet(BaseModel):
125
125
  out += [r.summary for r in self.renames]
126
126
  out += [d.summary for d in self.deletes]
127
127
  return out
128
+
129
+
130
+ def reference_edit_is_mappable(edit: ReferenceEdit) -> bool:
131
+ """Whether both appliers can express `edit` as a flat member-field rewrite.
132
+
133
+ The single source of truth behind `apply_xml._referrer_field_element` and
134
+ `apply_live._referrer_field_xpath`: a member field they know how to address
135
+ (a group's `static`/`members`, a security-rule field, a NAT src/dst list).
136
+ A NAT *translation* field is nested with no flat list, and a rule edit with
137
+ no `rulebase` can't be located — both are unmappable, and an applier silently
138
+ skips them. The planner uses this to refuse such a skip when the same plan
139
+ tears the target down, instead of leaving a dangling reference (#28).
140
+ """
141
+ kind = edit.referrer_kind
142
+ if kind in ("address-group", "service-group"):
143
+ return True
144
+ if kind == "security-rule":
145
+ return edit.rulebase is not None
146
+ if kind == "nat-rule":
147
+ return edit.rulebase is not None and edit.field in ("source", "destination")
148
+ return False
149
+
150
+
151
+ def gate_unmappable_reference_edits(cs: ChangeSet) -> None:
152
+ """Promote unsafe unmappable reference edits to blockers, in place.
153
+
154
+ An unmappable edit (see `reference_edit_is_mappable`) is silently skipped at
155
+ apply time. If the plan also deletes or renames an object, that skip drops
156
+ the repoint that kept the teardown safe — a dangling reference, the exact
157
+ failure repoint-before-delete exists to prevent. Make it a hard blocker. With
158
+ no teardown the edit is advisory only, so it stays a warning.
159
+
160
+ Clearing the ops is the planner's job (a blocked plan carries zero ops); this
161
+ only classifies.
162
+ """
163
+ unmappable = [e for e in cs.reference_edits if not reference_edit_is_mappable(e)]
164
+ if not unmappable:
165
+ return
166
+ torn_down = [d.name for d in cs.deletes] + [r.old_name for r in cs.renames]
167
+ for e in unmappable:
168
+ referrer = f"{e.referrer_kind} '{e.referrer_name}'@{e.referrer_location} {e.field}"
169
+ if torn_down:
170
+ targets = ", ".join(f"'{t}'" for t in torn_down)
171
+ cs.blockers.append(
172
+ f"cannot repoint {referrer} automatically (no flat member list to rewrite); "
173
+ f"removing/renaming {targets} would leave a dangling reference — edit that "
174
+ "field manually, then re-run"
175
+ )
176
+ else:
177
+ cs.warnings.append(
178
+ f"{referrer} can't be repointed automatically (no flat member list); "
179
+ "review and edit it by hand"
180
+ )
@@ -14,7 +14,13 @@ from __future__ import annotations
14
14
 
15
15
  from pydantic import BaseModel, Field
16
16
 
17
- from psc.core.changeset import ChangeSet, ObjectDelete, ObjectKind, ReferenceEdit
17
+ from psc.core.changeset import (
18
+ ChangeSet,
19
+ ObjectDelete,
20
+ ObjectKind,
21
+ ReferenceEdit,
22
+ gate_unmappable_reference_edits,
23
+ )
18
24
  from psc.core.models import Location, Snapshot
19
25
  from psc.core.normalize import normalize_address, service_key
20
26
  from psc.core.refs import Reference, ReferenceGraph
@@ -155,23 +161,18 @@ def plan_merge(
155
161
  cs.warnings.append(
156
162
  f"{ref.referrer_kind} '{ref.referrer_name}' {ref.field} would be emptied"
157
163
  )
158
- if ref.field in ("source-translation", "destination-translation"):
159
- # psc cannot safely rewrite NAT translation fields offline (the XML
160
- # path is nested and renderer-flagged), so deleting `drop` would
161
- # leave a dangling translation reference. Refuse rather than warn.
162
- cs.blockers.append(
163
- f"NAT rule '{ref.referrer_name}' references '{drop.name}' in "
164
- f"{ref.field}; psc can't safely rewrite NAT translation offline — "
165
- "edit that field manually, then re-run the merge"
166
- )
164
+
165
+ cs.deletes.append(ObjectDelete(kind=ObjectKind.ADDRESS, name=drop.name, location=drop.location))
166
+ # Refuse any repoint the appliers would silently skip (e.g. a NAT translation
167
+ # field) now that the delete is in the plan — a skipped repoint + delete is a
168
+ # dangling reference. Mirrors `plan_rename`; offline and live share the gate.
169
+ gate_unmappable_reference_edits(cs)
167
170
 
168
171
  if cs.blockers:
169
172
  # Invariant: a blocked plan carries zero ops, so no consumer can execute
170
173
  # a partial rewrite by iterating ops without checking `is_blocked`.
171
174
  cs.reference_edits.clear()
172
- return cs
173
-
174
- cs.deletes.append(ObjectDelete(kind=ObjectKind.ADDRESS, name=drop.name, location=drop.location))
175
+ cs.deletes.clear()
175
176
  return cs
176
177
 
177
178
 
@@ -15,7 +15,13 @@ import re
15
15
 
16
16
  from pydantic import BaseModel
17
17
 
18
- from psc.core.changeset import ChangeSet, ObjectKind, ObjectRename, ReferenceEdit
18
+ from psc.core.changeset import (
19
+ ChangeSet,
20
+ ObjectKind,
21
+ ObjectRename,
22
+ ReferenceEdit,
23
+ gate_unmappable_reference_edits,
24
+ )
19
25
  from psc.core.dedup import field_members
20
26
  from psc.core.models import Address, AddressType, Location, Service, Snapshot
21
27
  from psc.core.refs import ReferenceGraph
@@ -182,4 +188,12 @@ def plan_rename(
182
188
  cs.renames.append(
183
189
  ObjectRename(kind=kind, location=location_name, old_name=old_name, new_name=new_name)
184
190
  )
191
+ # Refuse any repoint the appliers would silently skip (e.g. a NAT translation
192
+ # field): skipping it while renaming the object away leaves a dangling
193
+ # reference. Shared gate keeps offline and live identical (#28).
194
+ gate_unmappable_reference_edits(cs)
195
+ if cs.blockers:
196
+ # Invariant: a blocked plan carries zero ops (see `plan_merge`).
197
+ cs.reference_edits.clear()
198
+ cs.renames.clear()
185
199
  return cs
@@ -18,6 +18,7 @@ Writes are deliberately conservative:
18
18
  from __future__ import annotations
19
19
 
20
20
  import ssl
21
+ from enum import Enum
21
22
  from pathlib import Path
22
23
 
23
24
  from pydantic import BaseModel, Field
@@ -26,9 +27,23 @@ from psc.core.apply_xml import apply_changeset
26
27
  from psc.core.changeset import ChangeSet
27
28
  from psc.core.models import Snapshot
28
29
  from psc.core.parse import parse_config
30
+ from psc.core.setcmd import render_changeset
29
31
  from psc.output.errors import ErrorType, PscError
30
32
 
31
33
 
34
+ class ConfigFormat(str, Enum):
35
+ """Format of the artifact an offline `apply` writes to `--out`.
36
+
37
+ `xml` rewrites the whole exported config (loadable with `load config`);
38
+ `set` emits the equivalent PAN-OS `set` script (the creations/deletes/
39
+ repoints that achieve the same change) — easier to read and to paste into a
40
+ config session or `load config partial`.
41
+ """
42
+
43
+ XML = "xml"
44
+ SET = "set"
45
+
46
+
32
47
  class ApplyResult(BaseModel):
33
48
  applied: bool
34
49
  ops: int
@@ -83,7 +98,13 @@ class OfflineSource:
83
98
  except Exception as exc:
84
99
  raise PscError(f"failed to parse {self.path}: {exc}", ErrorType.INPUT) from exc
85
100
 
86
- def apply(self, cs: ChangeSet, *, out_path: str | Path | None) -> ApplyResult:
101
+ def apply(
102
+ self,
103
+ cs: ChangeSet,
104
+ *,
105
+ out_path: str | Path | None,
106
+ out_format: ConfigFormat = ConfigFormat.XML,
107
+ ) -> ApplyResult:
87
108
  if out_path is None:
88
109
  raise PscError(
89
110
  "offline apply needs --out PATH (the rewritten config is never "
@@ -93,6 +114,20 @@ class OfflineSource:
93
114
  out = Path(out_path)
94
115
  if out.resolve() == self.path.resolve():
95
116
  raise PscError("--out must differ from the source config path", ErrorType.CONFIG)
117
+ # The blocker gate is enforced here for *every* format — the XML path
118
+ # relies on `apply_changeset`, but the set path renders the plan without
119
+ # it, so refuse before writing rather than emit a `# BLOCKED` file.
120
+ if cs.is_blocked:
121
+ raise PscError(
122
+ "refusing to apply a blocked plan",
123
+ ErrorType.CONFLICT,
124
+ details={"blockers": cs.blockers},
125
+ )
126
+ # Both artifacts share the same safety guards above; only the bytes differ.
127
+ if out_format is ConfigFormat.SET:
128
+ script = render_changeset(cs)
129
+ out.write_text("\n".join(script) + "\n", encoding="utf-8")
130
+ return ApplyResult(applied=True, ops=cs.op_count, out_path=str(out), set_script=script)
96
131
  new_xml = apply_changeset(self._xml, cs)
97
132
  out.write_text(new_xml, encoding="utf-8")
98
133
  return ApplyResult(applied=True, ops=cs.op_count, out_path=str(out))
@@ -221,9 +256,19 @@ class LiveSource:
221
256
  def snapshot(self) -> Snapshot:
222
257
  return parse_config(self.raw_xml())
223
258
 
224
- def apply(self, cs: ChangeSet, *, out_path: str | Path | None) -> ApplyResult:
259
+ def apply(
260
+ self,
261
+ cs: ChangeSet,
262
+ *,
263
+ out_path: str | Path | None,
264
+ out_format: ConfigFormat = ConfigFormat.XML,
265
+ ) -> ApplyResult:
225
266
  """Push `cs` to Panorama's candidate config over the XML API.
226
267
 
268
+ `out_path`/`out_format` are accepted for interface parity with the
269
+ offline source but ignored — a live apply leaves a candidate on the
270
+ device, not a file, so there is no artifact to format.
271
+
227
272
  The plan is lowered to xpath set/edit/delete/rename ops and replayed in
228
273
  order (repoint before delete). The `blockers` gate and name-addressing
229
274
  check run *before* any device contact, so a refused plan never writes.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "panorama-super-cli"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"