panorama-super-cli 0.2.2__tar.gz → 0.2.4__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.2 → panorama_super_cli-0.2.4}/CHANGELOG.md +27 -0
  2. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/PKG-INFO +1 -1
  3. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/duplicates-and-merging.md +21 -0
  4. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/output-formats.md +19 -0
  5. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/safety.md +10 -0
  6. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/reference/cli.md +9 -7
  7. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/_version.py +1 -1
  8. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/_plan.py +27 -2
  9. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/dedup_cmds.py +21 -6
  10. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/name_cmds.py +12 -5
  11. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/dedup.py +17 -5
  12. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/normalize.py +32 -4
  13. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/source.py +47 -2
  14. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/pyproject.toml +1 -1
  15. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/skills/panorama-super-cli/SKILL.md +18 -3
  16. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_cli.py +75 -0
  17. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_dedup.py +45 -1
  18. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_normalize.py +27 -0
  19. panorama_super_cli-0.2.4/tests/test_source.py +62 -0
  20. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/uv.lock +577 -577
  21. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/CODEOWNERS +0 -0
  22. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  23. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  24. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/dependabot.yml +0 -0
  26. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/docs.yml +0 -0
  27. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/lint.yml +0 -0
  28. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/release.yml +0 -0
  29. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.github/workflows/test.yml +0 -0
  30. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.gitignore +0 -0
  31. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.pre-commit-config.yaml +0 -0
  32. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/.python-version +0 -0
  33. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/AGENTS.md +0 -0
  34. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/CLAUDE.md +0 -0
  35. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/LICENSE +0 -0
  36. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/README.md +0 -0
  37. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/contributing/branching.md +0 -0
  38. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/contributing/development.md +0 -0
  39. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/contributing/release-process.md +0 -0
  40. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/getting-started/concepts.md +0 -0
  41. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/getting-started/first-run.md +0 -0
  42. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/getting-started/install.md +0 -0
  43. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/finding-objects.md +0 -0
  44. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/live-vs-offline.md +0 -0
  45. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/naming.md +0 -0
  46. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/references-and-audit.md +0 -0
  47. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/guides/using-with-ai-agents.md +0 -0
  48. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/index.md +0 -0
  49. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/reference/config.md +0 -0
  50. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/docs/reference/exit-codes.md +0 -0
  51. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/justfile +0 -0
  52. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/mkdocs.yml +0 -0
  53. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/__init__.py +0 -0
  54. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/__main__.py +0 -0
  55. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/__init__.py +0 -0
  56. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/app.py +0 -0
  57. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/auth_cmds.py +0 -0
  58. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/find_cmds.py +0 -0
  59. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/profile_cmds.py +0 -0
  60. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/refs_cmds.py +0 -0
  61. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/cli/runtime.py +0 -0
  62. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/config/__init__.py +0 -0
  63. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/config/loader.py +0 -0
  64. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/config/models.py +0 -0
  65. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/__init__.py +0 -0
  66. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/apply_live.py +0 -0
  67. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/apply_xml.py +0 -0
  68. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/changeset.py +0 -0
  69. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/models.py +0 -0
  70. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/naming.py +0 -0
  71. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/parse.py +0 -0
  72. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/refs.py +0 -0
  73. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/resolve.py +0 -0
  74. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/core/setcmd.py +0 -0
  75. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/output/__init__.py +0 -0
  76. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/output/errors.py +0 -0
  77. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/psc/output/format.py +0 -0
  78. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/scripts/sync_agents_md.py +0 -0
  79. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/__init__.py +0 -0
  80. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/conftest.py +0 -0
  81. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/fixtures/nested-device-groups.xml +0 -0
  82. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/fixtures/panorama-config.xml +0 -0
  83. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_apply_xml.py +0 -0
  84. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_auth.py +0 -0
  85. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_changeset.py +0 -0
  86. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_cli_auth.py +0 -0
  87. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_hardening.py +0 -0
  88. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_live_apply.py +0 -0
  89. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_naming.py +0 -0
  90. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_nested_dg.py +0 -0
  91. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_output.py +0 -0
  92. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_parse.py +0 -0
  93. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_refs.py +0 -0
  94. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_resolve.py +0 -0
  95. {panorama_super_cli-0.2.2 → panorama_super_cli-0.2.4}/tests/test_setcmd.py +0 -0
@@ -7,6 +7,33 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.2.4 — 2026-06-04
11
+
12
+ ### Changed
13
+
14
+ - **`psc dedup addresses` is now strict by default** (#38). Addresses are
15
+ grouped only when their values are byte-identical, so a host accidentally
16
+ written with a subnet mask (`10.1.1.50/24`) is no longer reported as a
17
+ duplicate of the network `10.1.1.0/24` — merging those would have silently
18
+ changed rule matching. Genuinely identical forms (`10.0.0.10` and
19
+ `10.0.0.10/32`) still group. Pass `--not-strict` for the previous
20
+ host-bit-masking behaviour. The `dedup merge` safety gate now compares exact
21
+ values too, so such a merge is blocked unless `--allow-value-change` is given.
22
+
23
+ ## v0.2.3 — 2026-06-04
24
+
25
+ ### Added
26
+
27
+ - **`-of` / `--output-format xml|set` for the offline `--apply --out` artifact.**
28
+ Mutating commands (`dedup merge`, `name rename`, `name apply`) can now write the
29
+ rewritten config file as the equivalent PAN-OS `set` script instead of XML.
30
+ `xml` (the rewritten, `load config`-able config) stays the default, so existing
31
+ invocations are unchanged; `set` writes the creates/deletes/repoints that
32
+ achieve the same change, which is easier to read and to paste into a config
33
+ session or `load config partial`. This is distinct from `-o set`, which renders
34
+ the dry-run plan to stdout. The blocker hard-gate and repoint-before-delete
35
+ ordering apply to both formats; a blocked plan writes no file. (#37)
36
+
10
37
  ## v0.2.2 — 2026-06-04
11
38
 
12
39
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
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/
@@ -12,6 +12,27 @@ Objects are grouped by **value**, not name. `h-web1`, `web-primary`, and
12
12
  `svc-https` (both TCP/443) in another. Each row tells you the value and every
13
13
  object name + location that defines it.
14
14
 
15
+ ### Strict by default
16
+
17
+ Address matching is **strict**: only byte-identical values are duplicates. A
18
+ host accidentally written with a subnet mask — `web-server = 10.1.1.50/24` —
19
+ is **not** reported as a duplicate of the network `internal = 10.1.1.0/24`,
20
+ even though both mask down to the same `/24`. (`10.0.0.10` and `10.0.0.10/32`
21
+ *are* still the same host, so they group.)
22
+
23
+ Pass `--not-strict` for the looser, fringe behaviour that masks host bits and
24
+ collapses a host-with-mask onto its network:
25
+
26
+ ```console
27
+ psc -c panorama.xml dedup addresses --not-strict
28
+ ```
29
+
30
+ `--not-strict` only widens what's *listed* — it grants no merge power. A pair it
31
+ surfaces (e.g. a host and its network) has different exact values, so
32
+ [`dedup merge`](#the-safety-gate) still refuses it unless you pass
33
+ `--allow-value-change`. Treat `--not-strict` as a discovery aid, then decide
34
+ case by case whether the masked-equal objects really should be merged.
35
+
15
36
  ## Merge two objects
16
37
 
17
38
  `dedup merge` collapses one object (`--remove`) into another (`--keep`),
@@ -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
 
@@ -38,11 +40,11 @@ object by exact name. See [Finding objects](../guides/finding-objects.md).
38
40
  ### dedup
39
41
 
40
42
  ```
41
- psc dedup addresses
43
+ psc dedup addresses [--not-strict]
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.2"
7
+ __version__ = "0.2.4"
@@ -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 "")
@@ -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
 
@@ -29,10 +30,23 @@ def _dup_rows(groups: list[DuplicateGroup]) -> list[dict[str, object]]:
29
30
 
30
31
 
31
32
  @app.command("addresses")
32
- def addresses(ctx: typer.Context) -> None:
33
- """List address objects that share an identical value under different names."""
33
+ def addresses(
34
+ ctx: typer.Context,
35
+ not_strict: bool = typer.Option(
36
+ False,
37
+ "--not-strict",
38
+ help="Also group host objects onto their network (mask host bits). "
39
+ "Default: only byte-identical values are duplicates.",
40
+ ),
41
+ ) -> None:
42
+ """List address objects that share an identical value under different names.
43
+
44
+ By default this is *strict*: a host written with a subnet mask
45
+ (`10.1.1.50/24`) is not treated as a duplicate of the network `10.1.1.0/24`.
46
+ Pass `--not-strict` for the looser, host-bit-masking behaviour.
47
+ """
34
48
  rt: Runtime = ctx.obj
35
- groups = find_duplicate_addresses(rt.snapshot())
49
+ groups = find_duplicate_addresses(rt.snapshot(), strict=not not_strict)
36
50
  if rt.strict and not groups:
37
51
  raise PscError("no duplicate addresses", ErrorType.NOT_FOUND)
38
52
  render(
@@ -71,8 +85,9 @@ def merge(
71
85
  ),
72
86
  apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
73
87
  out: str | None = typer.Option(
74
- None, "--out", help="Offline: write the rewritten config XML here."
88
+ None, "--out", help="Offline: write the rewritten config here (see --output-format)."
75
89
  ),
90
+ output_format: ConfigFormat = OUT_FORMAT_OPTION,
76
91
  ) -> None:
77
92
  """Collapse one address object into another, repointing every reference.
78
93
 
@@ -91,4 +106,4 @@ def merge(
91
106
  drop=ObjectRef(name=remove, location=remove_location or default_loc),
92
107
  allow_value_change=allow_value_change,
93
108
  )
94
- complete(rt, cs, apply=apply, out_path=out)
109
+ 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)
@@ -45,21 +45,30 @@ class DuplicateGroup(BaseModel):
45
45
  return len(self.members)
46
46
 
47
47
 
48
- def find_duplicate_addresses(snapshot: Snapshot) -> list[DuplicateGroup]:
49
- """Address objects sharing an identical normalized value, 2+ per bucket."""
48
+ def find_duplicate_addresses(snapshot: Snapshot, *, strict: bool = True) -> list[DuplicateGroup]:
49
+ """Address objects sharing the same value, 2+ per bucket.
50
+
51
+ `strict` (default) groups only byte-identical values, so a host written with
52
+ a subnet mask (`10.1.1.50/24`) is *not* a duplicate of its network
53
+ (`10.1.1.0/24`). `strict=False` groups by masked network — the looser,
54
+ fringe behaviour that collapses host-with-mask onto the network.
55
+ """
50
56
  buckets: dict[str, list[ObjectRef]] = {}
51
57
  display: dict[str, str] = {}
52
58
  for a in snapshot.addresses:
53
59
  nv = normalize_address(a)
54
60
  if nv is None:
55
61
  continue
56
- key = nv.overlaps_key()
62
+ key = nv.exact_key() if strict else nv.overlaps_key()
57
63
  buckets.setdefault(key, []).append(ObjectRef(name=a.name, location=a.location.name))
58
- display.setdefault(key, f"{a.type.value} {nv.key}")
64
+ label = (nv.exact or nv.key) if strict else nv.key
65
+ display.setdefault(key, f"{a.type.value} {label}")
59
66
  return _to_groups("address", buckets, display)
60
67
 
61
68
 
62
69
  def find_duplicate_services(snapshot: Snapshot) -> list[DuplicateGroup]:
70
+ # Service dedup is already exact (protocol + normalized port lists); there
71
+ # is no host-bit-masking analogue, so no strict/loose distinction applies.
63
72
  buckets: dict[str, list[ObjectRef]] = {}
64
73
  display: dict[str, str] = {}
65
74
  for s in snapshot.services:
@@ -93,10 +102,13 @@ def _rewrite_members(before: list[str], drop: str, keep: str) -> list[str]:
93
102
 
94
103
 
95
104
  def _addr_value_key(snapshot: Snapshot, ref: ObjectRef) -> str | None:
105
+ # Exact (host-bit-preserving) key: the merge gate must treat a /24-masked
106
+ # host and the /24 network as *different* values, so it blocks the merge
107
+ # unless --allow-value-change is passed.
96
108
  for a in snapshot.addresses:
97
109
  if a.name == ref.name and a.location == ref.loc:
98
110
  nv = normalize_address(a)
99
- return nv.overlaps_key() if nv else None
111
+ return nv.exact_key() if nv else None
100
112
  return None
101
113
 
102
114
 
@@ -41,11 +41,20 @@ class AddrValue:
41
41
  range: tuple[int, int] | None = None
42
42
  family: int | None = None
43
43
  fqdn: str | None = None
44
+ exact: str | None = None
45
+ """Host-bit-preserving canonical string for ip-netmask (e.g. `10.1.1.50/24`
46
+ stays distinct from `10.1.1.0/24`); `None` means `key` is already exact."""
44
47
 
45
48
  def overlaps_key(self) -> str:
46
- """Kind-qualified key for grouping exact duplicates."""
49
+ """Kind-qualified key grouping by *masked network* — loose: a host
50
+ written with a subnet mask collapses onto its network."""
47
51
  return f"{self.kind.value}:{self.key}"
48
52
 
53
+ def exact_key(self) -> str:
54
+ """Kind-qualified key grouping only *byte-identical* values — strict:
55
+ host bits are preserved, so `10.1.1.50/24` != `10.1.1.0/24`."""
56
+ return f"{self.kind.value}:{self.exact or self.key}"
57
+
49
58
 
50
59
  def _as_network(value: str) -> IPNetwork | None:
51
60
  try:
@@ -54,6 +63,16 @@ def _as_network(value: str) -> IPNetwork | None:
54
63
  return None
55
64
 
56
65
 
66
+ def _as_interface(value: str) -> ipaddress.IPv4Interface | ipaddress.IPv6Interface | None:
67
+ """Parse host-preserving: `10.1.1.50/24` keeps its host bits, `10.0.0.10`
68
+ becomes `10.0.0.10/32`. `.network` is the masked form (equals
69
+ `_as_network`), `str(...)` is the exact form. `None` if unparseable."""
70
+ try:
71
+ return ipaddress.ip_interface(value.strip())
72
+ except ValueError:
73
+ return None
74
+
75
+
57
76
  def _range_bounds(value: str) -> tuple[int, int, int] | None:
58
77
  """`a-b` -> (start_int, end_int, family); None if unparseable."""
59
78
  if "-" not in value:
@@ -75,10 +94,19 @@ def normalize_address(addr: Address) -> AddrValue | None:
75
94
  """
76
95
  v = addr.value.strip()
77
96
  if addr.type is AddressType.IP_NETMASK:
78
- net = _as_network(v)
79
- if net is None:
97
+ # One parse yields both keys: `.network` is the masked grouping key
98
+ # (loose), `str(iface)` preserves host bits for the exact key (strict).
99
+ iface = _as_interface(v)
100
+ if iface is None:
80
101
  return None
81
- return AddrValue(kind=addr.type, key=str(net), network=net, family=net.version)
102
+ net = iface.network
103
+ return AddrValue(
104
+ kind=addr.type,
105
+ key=str(net),
106
+ network=net,
107
+ family=net.version,
108
+ exact=str(iface),
109
+ )
82
110
  if addr.type is AddressType.IP_RANGE:
83
111
  bounds = _range_bounds(v)
84
112
  if bounds is None:
@@ -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.2"
3
+ version = "0.2.4"
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"
@@ -29,7 +29,8 @@ psc [GLOBAL OPTS] <group> <command> [ARGS] [WRITE OPTS]
29
29
  - **Global (context) options go *before* the group:** `-c/--config FILE`,
30
30
  `-p/--profile NAME`, `-o/--output FMT`, `-d/--device-group NAME`, `--strict`,
31
31
  `--debug`.
32
- - **Write-execution options go *after* the command:** `--apply`, `--out PATH`.
32
+ - **Write-execution options go *after* the command:** `--apply`, `--out PATH`,
33
+ `-of/--output-format xml|set`.
33
34
 
34
35
  ```bash
35
36
  psc -c panorama.xml -o json find ip 10.0.0.10
@@ -39,7 +40,20 @@ psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out
39
40
  ## Pick a source
40
41
 
41
42
  - **Offline:** `-c panorama.xml` — an exported config. Read-only against the
42
- device; `--apply` writes a *new* file via `--out`.
43
+ device; `--apply` writes a *new* file via `--out` (it never overwrites the
44
+ source export). Choose what that file holds with `-of/--output-format`:
45
+ - `xml` (default) — the whole config rewritten, loadable with `load config`.
46
+ - `set` — just the equivalent PAN-OS `set` script (the creates/deletes/
47
+ repoints that achieve the change). Easier to read and to paste into a
48
+ config session or `load config partial`. Use this when a human reviews the
49
+ change or you want a minimal, diff-like artifact; use `xml` when you need a
50
+ full, directly-loadable replacement config.
51
+
52
+ ```bash
53
+ psc -c cfg.xml dedup merge --keep a --remove b --apply --out plan.set -of set
54
+ ```
55
+ (`-of` only shapes the `--out` file; for the script on stdout in a dry-run,
56
+ use `-o set` instead.)
43
57
  - **Live:** `-p prod` — fetches the running config over the XML API. `--apply`
44
58
  (no `--out`) pushes the plan to Panorama's **candidate** config and never
45
59
  commits; you review and commit yourself.
@@ -64,7 +78,8 @@ like `10.0.0.0/8` would otherwise drown out the host you asked for.
64
78
  ### dedup — duplicates and merging
65
79
 
66
80
  ```bash
67
- psc -c cfg.xml -o json dedup addresses # same value, different names
81
+ psc -c cfg.xml -o json dedup addresses # strict: byte-identical values only
82
+ psc -c cfg.xml -o json dedup addresses --not-strict # also mask host bits (10.1.1.50/24 ~ 10.1.1.0/24)
68
83
  psc -c cfg.xml -o json dedup services
69
84
  psc -c cfg.xml -o json dedup merge --keep h-web1 --remove web-primary # dry-run plan
70
85
  psc -c cfg.xml dedup merge --keep h-web1 --remove web-primary --apply --out fixed.xml
@@ -64,6 +64,36 @@ def test_strict_not_found_exit_5() -> None:
64
64
  assert json.loads(cp.stdout)["type"] == "not_found"
65
65
 
66
66
 
67
+ _HOST_AND_NET_CONFIG = """<?xml version="1.0"?>
68
+ <config version="11.0.0">
69
+ <shared>
70
+ <address>
71
+ <entry name="host-with-mask"><ip-netmask>10.1.1.50/24</ip-netmask></entry>
72
+ <entry name="real-network"><ip-netmask>10.1.1.0/24</ip-netmask></entry>
73
+ </address>
74
+ </shared>
75
+ </config>
76
+ """
77
+
78
+
79
+ def test_dedup_addresses_strict_default_finds_no_duplicates(tmp_path: Path) -> None:
80
+ cfg = tmp_path / "cfg.xml"
81
+ cfg.write_text(_HOST_AND_NET_CONFIG)
82
+ cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses")
83
+ assert cp.returncode == 0
84
+ assert json.loads(cp.stdout) == []
85
+
86
+
87
+ def test_dedup_addresses_not_strict_groups_host_with_network(tmp_path: Path) -> None:
88
+ cfg = tmp_path / "cfg.xml"
89
+ cfg.write_text(_HOST_AND_NET_CONFIG)
90
+ cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses", "--not-strict")
91
+ assert cp.returncode == 0
92
+ groups = json.loads(cp.stdout)
93
+ assert len(groups) == 1
94
+ assert {m["name"] for m in groups[0]["members"]} == {"host-with-mask", "real-network"}
95
+
96
+
67
97
  def test_merge_dry_run_exit_0_writes_nothing(tmp_path: Path) -> None:
68
98
  out = tmp_path / "x.xml"
69
99
  cp = run(
@@ -122,6 +152,51 @@ def test_merge_apply_writes_out(tmp_path: Path) -> None:
122
152
  assert "web-primary" not in out.read_text(encoding="utf-8")
123
153
 
124
154
 
155
+ def test_merge_apply_out_set_writes_set_script(tmp_path: Path) -> None:
156
+ out = tmp_path / "fixed.set"
157
+ cp = run(
158
+ "-c",
159
+ str(FIXTURE),
160
+ "dedup",
161
+ "merge",
162
+ "--keep",
163
+ "h-web1",
164
+ "--remove",
165
+ "web-primary",
166
+ "--apply",
167
+ "--out",
168
+ str(out),
169
+ "-of",
170
+ "set",
171
+ )
172
+ assert cp.returncode == 0
173
+ assert out.exists()
174
+ text = out.read_text(encoding="utf-8")
175
+ assert "delete shared address web-primary" in text
176
+ assert "<entry" not in text # the --out artifact is a set script, not XML
177
+
178
+
179
+ def test_merge_apply_out_xml_is_default(tmp_path: Path) -> None:
180
+ out = tmp_path / "fixed.xml"
181
+ cp = run(
182
+ "-c",
183
+ str(FIXTURE),
184
+ "dedup",
185
+ "merge",
186
+ "--keep",
187
+ "h-web1",
188
+ "--remove",
189
+ "web-primary",
190
+ "--apply",
191
+ "--out",
192
+ str(out),
193
+ "--output-format",
194
+ "xml",
195
+ )
196
+ assert cp.returncode == 0
197
+ assert "web-primary" not in out.read_text(encoding="utf-8") # rewritten XML, object gone
198
+
199
+
125
200
  def test_no_source_errors_config() -> None:
126
201
  cp = run("-o", "json", "find", "ip", "10.0.0.10")
127
202
  assert cp.returncode == 9