panorama-super-cli 0.2.0__tar.gz → 0.2.2__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 (94) hide show
  1. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/docs.yml +4 -4
  2. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/lint.yml +4 -4
  3. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/release.yml +2 -2
  4. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/workflows/test.yml +2 -2
  5. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/CHANGELOG.md +30 -0
  6. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/PKG-INFO +2 -2
  7. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/README.md +1 -1
  8. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/_version.py +1 -1
  9. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/app.py +37 -5
  10. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/find_cmds.py +6 -1
  11. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/changeset.py +53 -0
  12. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/dedup.py +14 -13
  13. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/naming.py +15 -1
  14. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/resolve.py +15 -6
  15. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/pyproject.toml +1 -1
  16. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/skills/panorama-super-cli/SKILL.md +3 -0
  17. panorama_super_cli-0.2.2/tests/test_changeset.py +172 -0
  18. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_cli.py +68 -0
  19. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_dedup.py +19 -0
  20. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_naming.py +19 -0
  21. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_resolve.py +30 -0
  22. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/uv.lock +1 -1
  23. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/CODEOWNERS +0 -0
  24. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  25. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.github/dependabot.yml +0 -0
  28. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.gitignore +0 -0
  29. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.pre-commit-config.yaml +0 -0
  30. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/.python-version +0 -0
  31. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/AGENTS.md +0 -0
  32. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/CLAUDE.md +0 -0
  33. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/LICENSE +0 -0
  34. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/contributing/branching.md +0 -0
  35. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/contributing/development.md +0 -0
  36. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/contributing/release-process.md +0 -0
  37. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/getting-started/concepts.md +0 -0
  38. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/getting-started/first-run.md +0 -0
  39. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/getting-started/install.md +0 -0
  40. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/duplicates-and-merging.md +0 -0
  41. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/finding-objects.md +0 -0
  42. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/live-vs-offline.md +0 -0
  43. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/naming.md +0 -0
  44. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/output-formats.md +0 -0
  45. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/references-and-audit.md +0 -0
  46. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/safety.md +0 -0
  47. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/guides/using-with-ai-agents.md +0 -0
  48. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/index.md +0 -0
  49. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/reference/cli.md +0 -0
  50. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/reference/config.md +0 -0
  51. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/docs/reference/exit-codes.md +0 -0
  52. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/justfile +0 -0
  53. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/mkdocs.yml +0 -0
  54. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/__init__.py +0 -0
  55. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/__main__.py +0 -0
  56. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/__init__.py +0 -0
  57. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/_plan.py +0 -0
  58. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/auth_cmds.py +0 -0
  59. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/dedup_cmds.py +0 -0
  60. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/name_cmds.py +0 -0
  61. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/profile_cmds.py +0 -0
  62. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/refs_cmds.py +0 -0
  63. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/cli/runtime.py +0 -0
  64. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/config/__init__.py +0 -0
  65. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/config/loader.py +0 -0
  66. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/config/models.py +0 -0
  67. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/__init__.py +0 -0
  68. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/apply_live.py +0 -0
  69. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/apply_xml.py +0 -0
  70. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/models.py +0 -0
  71. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/normalize.py +0 -0
  72. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/parse.py +0 -0
  73. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/refs.py +0 -0
  74. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/setcmd.py +0 -0
  75. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/core/source.py +0 -0
  76. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/output/__init__.py +0 -0
  77. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/output/errors.py +0 -0
  78. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/psc/output/format.py +0 -0
  79. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/scripts/sync_agents_md.py +0 -0
  80. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/__init__.py +0 -0
  81. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/conftest.py +0 -0
  82. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/fixtures/nested-device-groups.xml +0 -0
  83. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/fixtures/panorama-config.xml +0 -0
  84. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_apply_xml.py +0 -0
  85. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_auth.py +0 -0
  86. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_cli_auth.py +0 -0
  87. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_hardening.py +0 -0
  88. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_live_apply.py +0 -0
  89. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_nested_dg.py +0 -0
  90. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_normalize.py +0 -0
  91. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_output.py +0 -0
  92. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_parse.py +0 -0
  93. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/tests/test_refs.py +0 -0
  94. {panorama_super_cli-0.2.0 → panorama_super_cli-0.2.2}/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,36 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.2.2 — 2026-06-04
11
+
12
+ ### Fixed
13
+
14
+ - **Repoint-before-delete no longer skipped silently.** A merge or rename whose
15
+ only safe repoint is a reference the appliers can't express as a flat member
16
+ rewrite — a NAT source/destination *translation* field, or a rule edit with no
17
+ rulebase — is now refused with a `blocker` (exit 6) on both the offline and
18
+ live paths, naming the object and the unmappable referrer. Previously the edit
19
+ was dropped while the delete/rename still ran, leaving a dangling reference.
20
+ Advisory unmappable edits with no accompanying teardown still pass with a
21
+ warning. (#28)
22
+ - **`psc` with no arguments (and unknown commands) print help, not a traceback.**
23
+ Typer 0.16+ vendors its own Click, so the exceptions the entry point raised
24
+ were not the `click.*` the wrapper caught and escaped as a traceback ending in
25
+ `NoArgsIsHelpError`. The wrapper now handles both the vendored and the real
26
+ Click hierarchies, resolving the module defensively so psc still imports on
27
+ older Typer. No-args/`--help` exit 0; usage errors exit 2. (#31)
28
+
29
+ ## v0.2.1 — 2026-06-04
30
+
31
+ ### Added
32
+
33
+ - **`find ip --exact` / `-e`.** Restrict `find ip` to objects whose value
34
+ equals the target exactly, dropping the broader (`contains`) and narrower
35
+ (`within`) matches that a host query normally also surfaces. Netmask and
36
+ bare-host forms still canonicalize equal, so `10.0.0.10` and `10.0.0.10/32`
37
+ remain exact matches of each other. Address-groups are reported only when
38
+ they carry an exact match. (#30)
39
+
10
40
  ## v0.2.0 — 2026-06-04
11
41
 
12
42
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
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/
@@ -81,7 +81,7 @@ pip install panorama-super-cli
81
81
 
82
82
  | Area | Commands (v0.1) |
83
83
  | --- | --- |
84
- | **Find / resolve** | `psc find ip <ip>`, `find ip -f ips.txt`, `find object <name>` |
84
+ | **Find / resolve** | `psc find ip <ip>`, `find ip -e <ip>` (exact only), `find ip -f ips.txt`, `find object <name>` |
85
85
  | **Duplicates** | `psc dedup addresses`, `dedup services`, `dedup merge` |
86
86
  | **Naming** | `psc name suggest`, `name lint`, `name apply` (opt-in templates) |
87
87
  | **References** | `psc refs <object>` (where-used), `refs unused` |
@@ -50,7 +50,7 @@ pip install panorama-super-cli
50
50
 
51
51
  | Area | Commands (v0.1) |
52
52
  | --- | --- |
53
- | **Find / resolve** | `psc find ip <ip>`, `find ip -f ips.txt`, `find object <name>` |
53
+ | **Find / resolve** | `psc find ip <ip>`, `find ip -e <ip>` (exact only), `find ip -f ips.txt`, `find object <name>` |
54
54
  | **Duplicates** | `psc dedup addresses`, `dedup services`, `dedup merge` |
55
55
  | **Naming** | `psc name suggest`, `name lint`, `name apply` (opt-in templates) |
56
56
  | **References** | `psc refs <object>` (where-used), `refs unused` |
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.2.2"
@@ -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
 
@@ -21,11 +21,16 @@ def ip(
21
21
  file: Path | None = typer.Option(
22
22
  None, "--file", "-f", help="Read targets from a file (one per line; # comments)."
23
23
  ),
24
+ exact: bool = typer.Option(
25
+ False, "--exact", "-e", help="Only exact matches (drop contains/within)."
26
+ ),
24
27
  ) -> None:
25
28
  """Find which address objects/groups match an IP (or a whole list).
26
29
 
27
30
  Reports exact matches, broader objects that *contain* the target, and
28
31
  narrower objects *within* it, plus the address-groups that carry them.
32
+ With --exact, only objects equal to the target are reported (netmask and
33
+ bare-host forms still count as equal, e.g. 10.0.0.10 == 10.0.0.10/32).
29
34
  """
30
35
  rt: Runtime = ctx.obj
31
36
  snap = rt.snapshot()
@@ -43,7 +48,7 @@ def ip(
43
48
  "provide one or more IP/CIDR/range/FQDN targets, or --file", ErrorType.VALIDATION
44
49
  )
45
50
 
46
- results = find_ips(snap, items, rt.scope())
51
+ results = find_ips(snap, items, rt.scope(), exact=exact)
47
52
  rows: list[dict[str, object]] = []
48
53
  for res in results:
49
54
  if not res.matches:
@@ -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
@@ -58,9 +58,17 @@ def _visible_names(snapshot: Snapshot, scope: Location | None) -> set[str] | Non
58
58
  return {loc.name for loc in snapshot.ancestors(scope)}
59
59
 
60
60
 
61
- def find_ip(snapshot: Snapshot, raw: str, scope: Location | None = None) -> FindResult:
61
+ def find_ip(
62
+ snapshot: Snapshot, raw: str, scope: Location | None = None, *, exact: bool = False
63
+ ) -> FindResult:
62
64
  """Find every address object/group matching `raw` within `scope` (which
63
- includes the scoped device-group's ancestors and `shared`)."""
65
+ includes the scoped device-group's ancestors and `shared`).
66
+
67
+ With `exact=True`, only objects whose value equals the query exactly are
68
+ kept — broader (`CONTAINS`) and narrower (`WITHIN`) matches are dropped.
69
+ Netmask and bare-host forms still canonicalize equal (`10.0.0.10` ==
70
+ `10.0.0.10/32`), so those remain exact.
71
+ """
64
72
  query = parse_query(raw)
65
73
  visible = _visible_names(snapshot, scope)
66
74
  matched: list[tuple[Address, MatchKind]] = []
@@ -71,8 +79,9 @@ def find_ip(snapshot: Snapshot, raw: str, scope: Location | None = None) -> Find
71
79
  if nv is None:
72
80
  continue
73
81
  mk = match(query, nv)
74
- if mk is not None:
75
- matched.append((addr, mk))
82
+ if mk is None or (exact and mk is not MatchKind.EXACT):
83
+ continue
84
+ matched.append((addr, mk))
76
85
 
77
86
  matches = [
78
87
  AddressMatch(
@@ -124,9 +133,9 @@ def find_ip(snapshot: Snapshot, raw: str, scope: Location | None = None) -> Find
124
133
 
125
134
 
126
135
  def find_ips(
127
- snapshot: Snapshot, raws: list[str], scope: Location | None = None
136
+ snapshot: Snapshot, raws: list[str], scope: Location | None = None, *, exact: bool = False
128
137
  ) -> list[FindResult]:
129
- return [find_ip(snapshot, r, scope) for r in raws]
138
+ return [find_ip(snapshot, r, scope, exact=exact) for r in raws]
130
139
 
131
140
 
132
141
  class ObjectHit(BaseModel):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "panorama-super-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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"
@@ -50,6 +50,7 @@ psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out
50
50
 
51
51
  ```bash
52
52
  psc -c cfg.xml -o json find ip 10.0.0.10 # exact/contains/within + groups
53
+ psc -c cfg.xml -o json find ip -e 10.0.0.10 # exact only (10.0.0.10 == /32)
53
54
  psc -c cfg.xml -o json find ip 10.0.0.0/24 # everything inside the /24
54
55
  psc -c cfg.xml -o json find ip -f ips.txt # a whole list (array result)
55
56
  psc -c cfg.xml -o json find object grp-web # locate by exact name
@@ -57,6 +58,8 @@ psc -c cfg.xml -o json find object grp-web # locate by exact name
57
58
 
58
59
  `exists: true` means there's an exact-match object. `matches[].match` is one of
59
60
  `exact` (equal), `contains` (object is broader), `within` (object is narrower).
61
+ Pass `--exact`/`-e` to keep only `exact` matches — handy when a broad object
62
+ like `10.0.0.0/8` would otherwise drown out the host you asked for.
60
63
 
61
64
  ### dedup — duplicates and merging
62
65
 
@@ -0,0 +1,172 @@
1
+ """Tests for the shared unmappable-reference gate (issue #28).
2
+
3
+ The gate is the single source of truth for "can both appliers express this
4
+ reference edit as a flat member rewrite?" When the answer is no *and* the same
5
+ plan tears the target down (delete/rename), the silently-skipped repoint would
6
+ leave a dangling reference — so it must become a hard blocker, not a warning.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from psc.core.apply_live import plan_xapi_ops
16
+ from psc.core.apply_xml import apply_changeset
17
+ from psc.core.changeset import (
18
+ ChangeSet,
19
+ ObjectDelete,
20
+ ObjectKind,
21
+ ObjectRename,
22
+ ReferenceEdit,
23
+ gate_unmappable_reference_edits,
24
+ reference_edit_is_mappable,
25
+ )
26
+ from psc.core.models import Snapshot
27
+ from psc.core.naming import plan_rename
28
+ from psc.core.refs import ReferenceGraph
29
+ from psc.output.errors import ErrorType, PscError
30
+
31
+
32
+ def _edit(**kw: object) -> ReferenceEdit:
33
+ base: dict[str, object] = {
34
+ "referrer_kind": "address-group",
35
+ "referrer_name": "g",
36
+ "referrer_location": "shared",
37
+ "field": "static",
38
+ }
39
+ base.update(kw)
40
+ return ReferenceEdit(**base) # type: ignore[arg-type]
41
+
42
+
43
+ def test_flat_member_fields_are_mappable() -> None:
44
+ assert reference_edit_is_mappable(_edit(referrer_kind="address-group", field="static"))
45
+ assert reference_edit_is_mappable(_edit(referrer_kind="service-group", field="members"))
46
+
47
+
48
+ def test_rule_field_is_mappable_only_with_rulebase() -> None:
49
+ assert reference_edit_is_mappable(
50
+ _edit(referrer_kind="security-rule", field="destination", rulebase="pre")
51
+ )
52
+ assert reference_edit_is_mappable(
53
+ _edit(referrer_kind="nat-rule", field="source", rulebase="pre")
54
+ )
55
+ # trigger 2: a rule edit with no rulebase can't be addressed by either applier
56
+ assert not reference_edit_is_mappable(
57
+ _edit(referrer_kind="security-rule", field="destination", rulebase=None)
58
+ )
59
+ assert not reference_edit_is_mappable(
60
+ _edit(referrer_kind="nat-rule", field="source", rulebase=None)
61
+ )
62
+
63
+
64
+ def test_nat_translation_and_nonmember_fields_are_unmappable() -> None:
65
+ # trigger 1: NAT translation fields are nested, not flat member lists
66
+ assert not reference_edit_is_mappable(
67
+ _edit(referrer_kind="nat-rule", field="source-translation", rulebase="pre")
68
+ )
69
+ assert not reference_edit_is_mappable(
70
+ _edit(referrer_kind="nat-rule", field="destination-translation", rulebase="pre")
71
+ )
72
+ # a nat-rule `service` is a scalar, not a member list either
73
+ assert not reference_edit_is_mappable(
74
+ _edit(referrer_kind="nat-rule", field="service", rulebase="pre")
75
+ )
76
+
77
+
78
+ def test_gate_blocks_unmappable_edit_when_plan_deletes_target() -> None:
79
+ cs = ChangeSet(
80
+ title="merge",
81
+ reference_edits=[
82
+ _edit(
83
+ referrer_kind="nat-rule",
84
+ referrer_name="nat-web",
85
+ referrer_location="shared",
86
+ field="source-translation",
87
+ rulebase="pre",
88
+ )
89
+ ],
90
+ deletes=[ObjectDelete(kind=ObjectKind.ADDRESS, name="net-10", location="shared")],
91
+ )
92
+ gate_unmappable_reference_edits(cs)
93
+ assert cs.is_blocked
94
+ blocker = " ".join(cs.blockers)
95
+ assert "net-10" in blocker # names the torn-down object
96
+ assert "nat-web" in blocker # names the unmappable referrer
97
+ assert "source-translation" in blocker
98
+
99
+
100
+ def test_gate_blocks_unmappable_edit_when_plan_renames_target() -> None:
101
+ cs = ChangeSet(
102
+ title="rename",
103
+ reference_edits=[
104
+ _edit(
105
+ referrer_kind="security-rule",
106
+ referrer_name="r1",
107
+ referrer_location="shared",
108
+ field="destination",
109
+ rulebase=None,
110
+ )
111
+ ],
112
+ renames=[
113
+ ObjectRename(
114
+ kind=ObjectKind.ADDRESS, location="shared", old_name="net-10", new_name="N-10"
115
+ )
116
+ ],
117
+ )
118
+ gate_unmappable_reference_edits(cs)
119
+ assert cs.is_blocked
120
+ assert any("net-10" in b and "r1" in b for b in cs.blockers)
121
+
122
+
123
+ def test_gate_warns_but_passes_when_no_teardown() -> None:
124
+ cs = ChangeSet(
125
+ title="advisory",
126
+ reference_edits=[
127
+ _edit(
128
+ referrer_kind="nat-rule",
129
+ referrer_name="nat-web",
130
+ referrer_location="shared",
131
+ field="source-translation",
132
+ rulebase="pre",
133
+ )
134
+ ],
135
+ )
136
+ gate_unmappable_reference_edits(cs)
137
+ assert not cs.is_blocked
138
+ assert any("nat-web" in w for w in cs.warnings)
139
+
140
+
141
+ def test_gate_noop_when_all_edits_mappable() -> None:
142
+ cs = ChangeSet(
143
+ title="clean",
144
+ reference_edits=[_edit(referrer_kind="address-group", field="static")],
145
+ deletes=[ObjectDelete(kind=ObjectKind.ADDRESS, name="a", location="shared")],
146
+ )
147
+ gate_unmappable_reference_edits(cs)
148
+ assert not cs.is_blocked
149
+ assert not cs.warnings
150
+
151
+
152
+ def test_engine_blocked_plan_refused_on_both_paths(snapshot: Snapshot, fixture_path: Path) -> None:
153
+ """The gate-produced blocker is honored identically offline and live: both
154
+ appliers refuse with CONFLICT (exit 6) before mutating anything (#28)."""
155
+ graph = ReferenceGraph.build(snapshot)
156
+ cs = plan_rename(
157
+ snapshot,
158
+ graph,
159
+ kind=ObjectKind.ADDRESS,
160
+ location_name="shared",
161
+ old_name="net-10",
162
+ new_name="N-10.0.0.0_24",
163
+ )
164
+ assert cs.is_blocked
165
+
166
+ with pytest.raises(PscError) as offline:
167
+ apply_changeset(fixture_path.read_text(), cs)
168
+ assert offline.value.error_type is ErrorType.CONFLICT
169
+
170
+ with pytest.raises(PscError) as live:
171
+ plan_xapi_ops(cs)
172
+ assert live.value.error_type is ErrorType.CONFLICT
@@ -1,11 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import builtins
3
4
  import json
4
5
  import os
5
6
  import subprocess
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
10
+ import click
11
+ import pytest
12
+
13
+ from psc.cli import app
14
+
9
15
  FIXTURE = Path(__file__).parent / "fixtures" / "panorama-config.xml"
10
16
 
11
17
 
@@ -34,6 +40,24 @@ def test_find_ip_json_contract() -> None:
34
40
  assert {m["name"] for m in data["matches"]} >= {"h-web1", "web-primary"}
35
41
 
36
42
 
43
+ def test_find_ip_exact_flag_json() -> None:
44
+ cp = run("-c", str(FIXTURE), "-o", "json", "find", "ip", "--exact", "10.0.0.10")
45
+ assert cp.returncode == 0
46
+ data = json.loads(cp.stdout)
47
+ assert data["exists"] is True
48
+ matches = data["matches"]
49
+ assert all(m["match"] == "exact" for m in matches)
50
+ names = {m["name"] for m in matches}
51
+ assert {"h-web1", "web-primary"} <= names
52
+ assert "net-10" not in names # the /24 is CONTAINS, dropped under --exact
53
+
54
+
55
+ def test_find_ip_exact_short_flag() -> None:
56
+ cp = run("-c", str(FIXTURE), "-o", "json", "find", "ip", "-e", "10.0.0.10")
57
+ assert cp.returncode == 0
58
+ assert all(m["match"] == "exact" for m in json.loads(cp.stdout)["matches"])
59
+
60
+
37
61
  def test_strict_not_found_exit_5() -> None:
38
62
  cp = run("-c", str(FIXTURE), "--strict", "-o", "json", "find", "ip", "203.0.113.9")
39
63
  assert cp.returncode == 5
@@ -102,3 +126,47 @@ def test_no_source_errors_config() -> None:
102
126
  cp = run("-o", "json", "find", "ip", "10.0.0.10")
103
127
  assert cp.returncode == 9
104
128
  assert json.loads(cp.stdout)["type"] == "config"
129
+
130
+
131
+ def test_no_args_prints_help_without_traceback() -> None:
132
+ # Typer's no_args_is_help raises a *vendored* click NoArgsIsHelpError that
133
+ # the main() wrapper (standalone_mode=False) must swallow cleanly (#31).
134
+ cp = run()
135
+ combined = cp.stdout + cp.stderr
136
+ assert cp.returncode == 0
137
+ assert "Usage:" in combined
138
+ assert "Traceback" not in combined
139
+ assert "NoArgsIsHelpError" not in combined
140
+
141
+
142
+ def test_unknown_command_usage_error_exit_2() -> None:
143
+ cp = run("no-such-command")
144
+ combined = cp.stdout + cp.stderr
145
+ assert cp.returncode == 2
146
+ assert "Traceback" not in combined
147
+ assert "No such command" in combined
148
+
149
+
150
+ def test_click_exception_module_resolves_with_required_attrs() -> None:
151
+ # main() reads ClickException/Exit/Abort off the resolved module, so it must
152
+ # expose them whichever Typer flavour is installed.
153
+ mod = app._click_exception_module()
154
+ for attr in ("ClickException", "Exit", "Abort"):
155
+ assert hasattr(mod, attr)
156
+
157
+
158
+ def test_click_exception_module_falls_back_when_typer_unvendored(
159
+ monkeypatch: pytest.MonkeyPatch,
160
+ ) -> None:
161
+ # Older Typer (<0.16) has no `typer._click`; a top-level import of it would
162
+ # crash psc at import time — worse than #31. Resolution must degrade to the
163
+ # real Click instead of raising.
164
+ real_import = builtins.__import__
165
+
166
+ def fake_import(name: str, *args: object, **kwargs: object) -> object:
167
+ if name.startswith("typer._click"):
168
+ raise ImportError("simulated Typer without vendored Click")
169
+ return real_import(name, *args, **kwargs) # type: ignore[arg-type]
170
+
171
+ monkeypatch.setattr(builtins, "__import__", fake_import)
172
+ assert app._click_exception_module() is click.exceptions
@@ -72,3 +72,22 @@ def test_merge_missing_object_blocks(snapshot: Snapshot) -> None:
72
72
  drop=ObjectRef(name="web-primary", location="shared"),
73
73
  )
74
74
  assert cs.is_blocked
75
+
76
+
77
+ def test_merge_blocks_when_repoint_hits_nat_translation(snapshot: Snapshot) -> None:
78
+ """net-10 is referenced by nat-web's source-translation, which has no flat
79
+ member list — repointing it away can't be expressed offline or live, so a
80
+ merge that would delete net-10 must be blocked, not silently skipped (#28).
81
+ """
82
+ graph = ReferenceGraph.build(snapshot)
83
+ cs = plan_merge(
84
+ snapshot,
85
+ graph,
86
+ keep=ObjectRef(name="h-web1", location="shared"),
87
+ drop=ObjectRef(name="net-10", location="shared"),
88
+ allow_value_change=True,
89
+ )
90
+ assert cs.is_blocked
91
+ assert any("net-10" in b and "nat-web" in b for b in cs.blockers)
92
+ # a blocked plan carries zero executable ops
93
+ assert cs.op_count == 0
@@ -64,6 +64,25 @@ def test_rename_blocks_on_existing_name(snapshot: Snapshot) -> None:
64
64
  assert cs.is_blocked
65
65
 
66
66
 
67
+ def test_rename_blocks_when_repoint_hits_nat_translation(snapshot: Snapshot) -> None:
68
+ """net-10 is referenced by nat-web's source-translation (a nested field with
69
+ no flat member list). The rename can repoint the security-rule destination
70
+ but not the translation field — so applying it would delete the old name out
71
+ from under a dangling reference. Block it (#28)."""
72
+ graph = ReferenceGraph.build(snapshot)
73
+ cs = plan_rename(
74
+ snapshot,
75
+ graph,
76
+ kind=ObjectKind.ADDRESS,
77
+ location_name="shared",
78
+ old_name="net-10",
79
+ new_name="N-10.0.0.0_24",
80
+ )
81
+ assert cs.is_blocked
82
+ assert any("net-10" in b and "nat-web" in b for b in cs.blockers)
83
+ assert cs.op_count == 0
84
+
85
+
67
86
  def test_rename_blocks_shared_dg_shadow() -> None:
68
87
  # Renaming a shared object to a name a DG already defines is refused.
69
88
  snap = Snapshot(
@@ -22,6 +22,36 @@ def test_find_ip_reports_groups(snapshot: Snapshot) -> None:
22
22
  assert set(grp["grp-web"].via) == {"h-web1", "web-primary"}
23
23
 
24
24
 
25
+ def test_find_ip_exact_flag_drops_contains_and_within(snapshot: Snapshot) -> None:
26
+ # Without --exact, the host matches its own objects (EXACT) and the
27
+ # enclosing /24 (CONTAINS). With exact=True only the EXACT ones survive.
28
+ res = find_ip(snapshot, "10.0.0.10", exact=True)
29
+ assert res.exists is True
30
+ kinds = {m.match for m in res.matches}
31
+ assert kinds == {MatchKind.EXACT}
32
+ names = {m.name for m in res.matches}
33
+ # /32 and bare-host forms normalize equal, so all three are EXACT...
34
+ assert {"h-web1", "web-primary", "h-web1-slash"} <= names
35
+ # ...and the broader /24 (CONTAINS) is excluded.
36
+ assert "net-10" not in names
37
+
38
+
39
+ def test_find_ip_exact_flag_slash32_equals_bare(snapshot: Snapshot) -> None:
40
+ # 10.0.0.10/32 and 10.0.0.10 are the same host; both query forms must hit
41
+ # both object forms under --exact.
42
+ for q in ("10.0.0.10", "10.0.0.10/32"):
43
+ names = {m.name for m in find_ip(snapshot, q, exact=True).matches}
44
+ assert {"h-web1", "h-web1-slash"} <= names, q
45
+
46
+
47
+ def test_find_ip_exact_flag_excludes_groups_with_only_broader(snapshot: Snapshot) -> None:
48
+ # A CIDR query that exactly matches no object yields nothing under --exact.
49
+ res = find_ip(snapshot, "10.0.0.0/8", exact=True)
50
+ assert res.exists is False
51
+ assert res.matches == []
52
+ assert res.groups == []
53
+
54
+
25
55
  def test_find_ip_no_match(snapshot: Snapshot) -> None:
26
56
  res = find_ip(snapshot, "203.0.113.1")
27
57
  assert res.exists is False
@@ -694,7 +694,7 @@ wheels = [
694
694
 
695
695
  [[package]]
696
696
  name = "panorama-super-cli"
697
- version = "0.2.0"
697
+ version = "0.2.2"
698
698
  source = { editable = "." }
699
699
  dependencies = [
700
700
  { name = "click" },