panorama-super-cli 0.2.3__tar.gz → 0.2.5__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 (96) hide show
  1. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/CHANGELOG.md +24 -0
  2. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/PKG-INFO +1 -1
  3. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/duplicates-and-merging.md +21 -0
  4. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/reference/cli.md +1 -1
  5. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/_version.py +1 -1
  6. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/dedup_cmds.py +16 -3
  7. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/find_cmds.py +3 -1
  8. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/dedup.py +17 -5
  9. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/normalize.py +32 -4
  10. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/output/format.py +11 -1
  11. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/pyproject.toml +1 -1
  12. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/skills/panorama-super-cli/SKILL.md +2 -1
  13. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_cli.py +38 -0
  14. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_dedup.py +45 -1
  15. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_normalize.py +27 -0
  16. panorama_super_cli-0.2.5/tests/test_output.py +65 -0
  17. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/uv.lock +577 -577
  18. panorama_super_cli-0.2.3/tests/test_output.py +0 -35
  19. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/CODEOWNERS +0 -0
  20. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  21. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  22. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/dependabot.yml +0 -0
  24. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/docs.yml +0 -0
  25. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/lint.yml +0 -0
  26. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/release.yml +0 -0
  27. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.github/workflows/test.yml +0 -0
  28. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.gitignore +0 -0
  29. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.pre-commit-config.yaml +0 -0
  30. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/.python-version +0 -0
  31. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/AGENTS.md +0 -0
  32. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/CLAUDE.md +0 -0
  33. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/LICENSE +0 -0
  34. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/README.md +0 -0
  35. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/contributing/branching.md +0 -0
  36. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/contributing/development.md +0 -0
  37. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/contributing/release-process.md +0 -0
  38. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/getting-started/concepts.md +0 -0
  39. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/getting-started/first-run.md +0 -0
  40. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/getting-started/install.md +0 -0
  41. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/finding-objects.md +0 -0
  42. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/live-vs-offline.md +0 -0
  43. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/naming.md +0 -0
  44. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/output-formats.md +0 -0
  45. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/references-and-audit.md +0 -0
  46. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/safety.md +0 -0
  47. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/guides/using-with-ai-agents.md +0 -0
  48. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/index.md +0 -0
  49. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/reference/config.md +0 -0
  50. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/docs/reference/exit-codes.md +0 -0
  51. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/justfile +0 -0
  52. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/mkdocs.yml +0 -0
  53. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/__init__.py +0 -0
  54. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/__main__.py +0 -0
  55. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/__init__.py +0 -0
  56. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/_plan.py +0 -0
  57. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/app.py +0 -0
  58. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/auth_cmds.py +0 -0
  59. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/name_cmds.py +0 -0
  60. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/profile_cmds.py +0 -0
  61. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/refs_cmds.py +0 -0
  62. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/cli/runtime.py +0 -0
  63. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/config/__init__.py +0 -0
  64. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/config/loader.py +0 -0
  65. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/config/models.py +0 -0
  66. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/__init__.py +0 -0
  67. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/apply_live.py +0 -0
  68. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/apply_xml.py +0 -0
  69. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/changeset.py +0 -0
  70. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/models.py +0 -0
  71. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/naming.py +0 -0
  72. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/parse.py +0 -0
  73. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/refs.py +0 -0
  74. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/resolve.py +0 -0
  75. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/setcmd.py +0 -0
  76. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/core/source.py +0 -0
  77. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/output/__init__.py +0 -0
  78. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/psc/output/errors.py +0 -0
  79. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/scripts/sync_agents_md.py +0 -0
  80. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/__init__.py +0 -0
  81. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/conftest.py +0 -0
  82. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/fixtures/nested-device-groups.xml +0 -0
  83. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/fixtures/panorama-config.xml +0 -0
  84. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_apply_xml.py +0 -0
  85. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_auth.py +0 -0
  86. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_changeset.py +0 -0
  87. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_cli_auth.py +0 -0
  88. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_hardening.py +0 -0
  89. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_live_apply.py +0 -0
  90. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_naming.py +0 -0
  91. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_nested_dg.py +0 -0
  92. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_parse.py +0 -0
  93. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_refs.py +0 -0
  94. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_resolve.py +0 -0
  95. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_setcmd.py +0 -0
  96. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.5}/tests/test_source.py +0 -0
@@ -7,6 +7,30 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.2.5 — 2026-06-04
11
+
12
+ ### Fixed
13
+
14
+ - **`psc find ip` table output now separates each target's matches with a
15
+ horizontal rule** (#43). Resolving several targets at once (notably with
16
+ `--file`) previously printed every match as one undifferentiated block, so it
17
+ was hard to see where one target's matches ended and the next began. The
18
+ table now draws a rule between per-target blocks. Machine output formats
19
+ (json/jsonl/yaml/csv/set) are unchanged.
20
+
21
+ ## v0.2.4 — 2026-06-04
22
+
23
+ ### Changed
24
+
25
+ - **`psc dedup addresses` is now strict by default** (#38). Addresses are
26
+ grouped only when their values are byte-identical, so a host accidentally
27
+ written with a subnet mask (`10.1.1.50/24`) is no longer reported as a
28
+ duplicate of the network `10.1.1.0/24` — merging those would have silently
29
+ changed rule matching. Genuinely identical forms (`10.0.0.10` and
30
+ `10.0.0.10/32`) still group. Pass `--not-strict` for the previous
31
+ host-bit-masking behaviour. The `dedup merge` safety gate now compares exact
32
+ values too, so such a merge is blocked unless `--allow-value-change` is given.
33
+
10
34
  ## v0.2.3 — 2026-06-04
11
35
 
12
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.3
3
+ Version: 0.2.5
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`),
@@ -40,7 +40,7 @@ object by exact name. See [Finding objects](../guides/finding-objects.md).
40
40
  ### dedup
41
41
 
42
42
  ```
43
- psc dedup addresses
43
+ psc dedup addresses [--not-strict]
44
44
  psc dedup services
45
45
  psc dedup merge --keep NAME --remove NAME [--location LOC]
46
46
  [--keep-location LOC] [--remove-location LOC]
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.2.3"
7
+ __version__ = "0.2.5"
@@ -30,10 +30,23 @@ def _dup_rows(groups: list[DuplicateGroup]) -> list[dict[str, object]]:
30
30
 
31
31
 
32
32
  @app.command("addresses")
33
- def addresses(ctx: typer.Context) -> None:
34
- """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
+ """
35
48
  rt: Runtime = ctx.obj
36
- groups = find_duplicate_addresses(rt.snapshot())
49
+ groups = find_duplicate_addresses(rt.snapshot(), strict=not not_strict)
37
50
  if rt.strict and not groups:
38
51
  raise PscError("no duplicate addresses", ErrorType.NOT_FOUND)
39
52
  render(
@@ -78,7 +78,9 @@ def ip(
78
78
  raise PscError("no matching objects", ErrorType.NOT_FOUND)
79
79
 
80
80
  model = results if len(results) != 1 else results[0]
81
- render(rt.stdout, rt.output, model=model, rows=rows, table_title="find ip")
81
+ # Group the table by query so multi-target output (e.g. --file) draws a rule
82
+ # between each target's matches instead of one undifferentiated block.
83
+ render(rt.stdout, rt.output, model=model, rows=rows, table_title="find ip", group_by="query")
82
84
 
83
85
 
84
86
  @app.command("object")
@@ -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:
@@ -104,12 +104,17 @@ def render(
104
104
  rows: list[dict[str, Any]] | None = None,
105
105
  set_lines: list[str] | None = None,
106
106
  table_title: str | None = None,
107
+ group_by: str | None = None,
107
108
  ) -> None:
108
109
  """Print `model`/`rows`/`set_lines` in `fmt` to `console`.
109
110
 
110
111
  The console must be the *stdout* console; errors go elsewhere. Table output
111
112
  is the only format that uses rich styling — every other format is plain text
112
113
  so pipes and captures stay clean.
114
+
115
+ `group_by` is a table-only hint: a column name whose changing value marks a
116
+ new block. A horizontal rule is drawn between blocks so multi-target output
117
+ (e.g. `find ip --file`) is easy to scan. It does not affect machine formats.
113
118
  """
114
119
  # `soft_wrap=True` is critical: without it rich wraps long lines to the
115
120
  # console width and injects newlines *inside* JSON string values, producing
@@ -135,8 +140,13 @@ def render(
135
140
  table = Table(title=table_title, header_style="bold cyan")
136
141
  for col in rows[0]:
137
142
  table.add_column(col)
138
- for row in rows:
143
+ prev: Any = None
144
+ for i, row in enumerate(rows):
145
+ if group_by and i > 0 and row.get(group_by) != prev:
146
+ table.add_section()
139
147
  table.add_row(*[_cell(row.get(col)) for col in rows[0]])
148
+ if group_by:
149
+ prev = row.get(group_by)
140
150
  console.print(table)
141
151
  else:
142
152
  console.print(_json(model), markup=False, highlight=False)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "panorama-super-cli"
3
- version = "0.2.3"
3
+ version = "0.2.5"
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"
@@ -78,7 +78,8 @@ like `10.0.0.0/8` would otherwise drown out the host you asked for.
78
78
  ### dedup — duplicates and merging
79
79
 
80
80
  ```bash
81
- 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)
82
83
  psc -c cfg.xml -o json dedup services
83
84
  psc -c cfg.xml -o json dedup merge --keep h-web1 --remove web-primary # dry-run plan
84
85
  psc -c cfg.xml dedup merge --keep h-web1 --remove web-primary --apply --out fixed.xml
@@ -64,6 +64,44 @@ def test_strict_not_found_exit_5() -> None:
64
64
  assert json.loads(cp.stdout)["type"] == "not_found"
65
65
 
66
66
 
67
+ def test_find_ip_table_separates_multiple_targets() -> None:
68
+ # Issue #43: multi-target table output must draw a rule between each
69
+ # target's matches. The interior divider uses box-drawing '├'.
70
+ cp = run("-c", str(FIXTURE), "-o", "table", "find", "ip", "10.0.0.10", "10.0.0.99")
71
+ assert cp.returncode == 0
72
+ assert "├" in cp.stdout
73
+
74
+
75
+ _HOST_AND_NET_CONFIG = """<?xml version="1.0"?>
76
+ <config version="11.0.0">
77
+ <shared>
78
+ <address>
79
+ <entry name="host-with-mask"><ip-netmask>10.1.1.50/24</ip-netmask></entry>
80
+ <entry name="real-network"><ip-netmask>10.1.1.0/24</ip-netmask></entry>
81
+ </address>
82
+ </shared>
83
+ </config>
84
+ """
85
+
86
+
87
+ def test_dedup_addresses_strict_default_finds_no_duplicates(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")
91
+ assert cp.returncode == 0
92
+ assert json.loads(cp.stdout) == []
93
+
94
+
95
+ def test_dedup_addresses_not_strict_groups_host_with_network(tmp_path: Path) -> None:
96
+ cfg = tmp_path / "cfg.xml"
97
+ cfg.write_text(_HOST_AND_NET_CONFIG)
98
+ cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses", "--not-strict")
99
+ assert cp.returncode == 0
100
+ groups = json.loads(cp.stdout)
101
+ assert len(groups) == 1
102
+ assert {m["name"] for m in groups[0]["members"]} == {"host-with-mask", "real-network"}
103
+
104
+
67
105
  def test_merge_dry_run_exit_0_writes_nothing(tmp_path: Path) -> None:
68
106
  out = tmp_path / "x.xml"
69
107
  cp = run(
@@ -6,16 +6,45 @@ from psc.core.dedup import (
6
6
  find_duplicate_services,
7
7
  plan_merge,
8
8
  )
9
- from psc.core.models import Snapshot
9
+ from psc.core.models import Address, AddressType, Snapshot
10
10
  from psc.core.refs import ReferenceGraph
11
11
 
12
12
 
13
+ def _host_and_network() -> Snapshot:
14
+ """A host written with a subnet mask and a real network object: identical
15
+ only after host-bit masking, so a strict dedup must keep them apart."""
16
+ return Snapshot(
17
+ addresses=[
18
+ Address(name="host", type=AddressType.IP_NETMASK, value="10.1.1.50/24"),
19
+ Address(name="net", type=AddressType.IP_NETMASK, value="10.1.1.0/24"),
20
+ ]
21
+ )
22
+
23
+
13
24
  def test_duplicate_addresses_grouped_by_value(snapshot: Snapshot) -> None:
14
25
  groups = {g.value: {m.name for m in g.members} for g in find_duplicate_addresses(snapshot)}
15
26
  assert groups["ip-netmask 10.0.0.10/32"] == {"h-web1", "web-primary", "h-web1-slash"}
16
27
  assert groups["ip-netmask 192.168.1.1/32"] == {"edge-dup", "local-only"}
17
28
 
18
29
 
30
+ def test_dedup_strict_default_excludes_host_with_mask() -> None:
31
+ # Strict (default): a /24-masked host is NOT a duplicate of the /24 network.
32
+ assert find_duplicate_addresses(_host_and_network()) == []
33
+
34
+
35
+ def test_dedup_not_strict_groups_host_with_mask() -> None:
36
+ # --not-strict restores the host-bit-masking behaviour for the fringe case.
37
+ groups = find_duplicate_addresses(_host_and_network(), strict=False)
38
+ assert len(groups) == 1
39
+ assert {m.name for m in groups[0].members} == {"host", "net"}
40
+
41
+
42
+ def test_dedup_strict_still_groups_genuine_duplicates(snapshot: Snapshot) -> None:
43
+ # Strict must keep collapsing 10.0.0.10 and 10.0.0.10/32 — genuinely identical.
44
+ groups = {g.value: {m.name for m in g.members} for g in find_duplicate_addresses(snapshot)}
45
+ assert groups["ip-netmask 10.0.0.10/32"] == {"h-web1", "web-primary", "h-web1-slash"}
46
+
47
+
19
48
  def test_duplicate_services(snapshot: Snapshot) -> None:
20
49
  groups = find_duplicate_services(snapshot)
21
50
  members = {m.name for g in groups for m in g.members}
@@ -51,6 +80,21 @@ def test_merge_blocks_value_mismatch(snapshot: Snapshot) -> None:
51
80
  assert any("value mismatch" in b for b in cs.blockers)
52
81
 
53
82
 
83
+ def test_merge_blocks_host_vs_network_value_mismatch() -> None:
84
+ # The merge gate compares exact values: a /24-masked host and the /24
85
+ # network mean different things, so the merge is blocked without --force.
86
+ snap = _host_and_network()
87
+ graph = ReferenceGraph.build(snap)
88
+ cs = plan_merge(
89
+ snap,
90
+ graph,
91
+ keep=ObjectRef(name="net", location="shared"),
92
+ drop=ObjectRef(name="host", location="shared"),
93
+ )
94
+ assert cs.is_blocked
95
+ assert any("value mismatch" in b for b in cs.blockers)
96
+
97
+
54
98
  def test_merge_allows_value_change_when_forced(snapshot: Snapshot) -> None:
55
99
  graph = ReferenceGraph.build(snapshot)
56
100
  cs = plan_merge(
@@ -15,6 +15,33 @@ def test_host_and_slash32_normalize_equal() -> None:
15
15
  assert a.overlaps_key() == b.overlaps_key()
16
16
 
17
17
 
18
+ def test_exact_key_preserves_host_bits() -> None:
19
+ # Same masked network, different host bits: the loose key collapses them,
20
+ # the strict (exact) key keeps them apart.
21
+ host = normalize_address(_addr("10.1.1.50/24"))
22
+ net = normalize_address(_addr("10.1.1.0/24"))
23
+ assert host is not None and net is not None
24
+ assert host.overlaps_key() == net.overlaps_key()
25
+ assert host.exact_key() != net.exact_key()
26
+
27
+
28
+ def test_exact_key_unifies_host_and_slash32() -> None:
29
+ # A bare host and its /32 are genuinely identical under the strict key too.
30
+ a = normalize_address(_addr("10.0.0.10"))
31
+ b = normalize_address(_addr("10.0.0.10/32"))
32
+ assert a is not None and b is not None
33
+ assert a.exact_key() == b.exact_key()
34
+
35
+
36
+ def test_exact_key_canonicalizes_ipv6_forms() -> None:
37
+ # Strict must still group genuinely identical objects written differently:
38
+ # `0:0:0:0:0:0:0:1` and `::1` are the same host.
39
+ a = normalize_address(_addr("0:0:0:0:0:0:0:1"))
40
+ b = normalize_address(_addr("::1"))
41
+ assert a is not None and b is not None
42
+ assert a.exact_key() == b.exact_key()
43
+
44
+
18
45
  def test_exact_contains_within() -> None:
19
46
  host = normalize_address(_addr("10.0.0.10/32"))
20
47
  net = normalize_address(_addr("10.0.0.0/24"))
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+
6
+ from rich.console import Console
7
+
8
+ from psc.core.models import SHARED, Location
9
+ from psc.core.refs import Reference, Target
10
+ from psc.output.format import OutputFormat, render, to_jsonable
11
+
12
+
13
+ def test_location_serializes_as_name() -> None:
14
+ assert to_jsonable(SHARED) == "shared"
15
+ assert to_jsonable(Location.dg("DG1")) == "DG1"
16
+
17
+
18
+ def test_location_roundtrips_from_string() -> None:
19
+ loc = Location.model_validate("DG1")
20
+ assert loc.device_group == "DG1"
21
+ assert Location.model_validate("shared").is_shared
22
+
23
+
24
+ def test_dataclass_is_jsonable() -> None:
25
+ ref = Reference(
26
+ target_name="x",
27
+ namespace="address",
28
+ referrer_kind="security-rule",
29
+ referrer_name="r",
30
+ referrer_location=SHARED,
31
+ field="source",
32
+ resolved=Target("address", "x", SHARED),
33
+ )
34
+ payload = to_jsonable(ref)
35
+ # round-trips through json without error
36
+ json.dumps(payload)
37
+ assert payload["resolved"]["location"] == "shared"
38
+ assert payload["referrer_location"] == "shared"
39
+
40
+
41
+ def _render_table(rows: list[dict[str, object]], **kw: object) -> str:
42
+ buf = io.StringIO()
43
+ console = Console(file=buf, width=120, force_terminal=False, color_system=None)
44
+ render(console, OutputFormat.TABLE, rows=rows, table_title="t", **kw)
45
+ return buf.getvalue()
46
+
47
+
48
+ def test_table_group_by_inserts_divider_between_groups() -> None:
49
+ rows = [
50
+ {"query": "10.0.0.1", "object": "a"},
51
+ {"query": "10.0.0.1", "object": "b"},
52
+ {"query": "10.0.0.2", "object": "c"},
53
+ ]
54
+ plain = _render_table(rows)
55
+ grouped = _render_table(rows, group_by="query")
56
+ # Grouping draws one extra horizontal rule between the two query blocks.
57
+ assert grouped.count("\n") == plain.count("\n") + 1
58
+
59
+
60
+ def test_table_group_by_single_group_no_extra_divider() -> None:
61
+ rows = [
62
+ {"query": "10.0.0.1", "object": "a"},
63
+ {"query": "10.0.0.1", "object": "b"},
64
+ ]
65
+ assert _render_table(rows, group_by="query") == _render_table(rows)