panorama-super-cli 0.2.4__tar.gz → 0.2.6__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 (100) hide show
  1. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/CHANGELOG.md +39 -0
  2. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/PKG-INFO +2 -1
  3. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/getting-started/install.md +4 -1
  4. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/live-vs-offline.md +5 -0
  5. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/naming.md +4 -1
  6. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/output-formats.md +9 -8
  7. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/safety.md +8 -5
  8. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/reference/cli.md +27 -8
  9. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/_version.py +1 -1
  10. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/_plan.py +17 -3
  11. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/app.py +14 -3
  12. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/dedup_cmds.py +4 -1
  13. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/find_cmds.py +3 -1
  14. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/name_cmds.py +8 -2
  15. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/profile_cmds.py +10 -0
  16. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/runtime.py +6 -0
  17. panorama_super_cli-0.2.6/psc/cli/version_cmds.py +42 -0
  18. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/config/loader.py +4 -2
  19. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/source.py +96 -12
  20. panorama_super_cli-0.2.6/psc/core/version_check.py +66 -0
  21. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/output/format.py +11 -1
  22. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/pyproject.toml +2 -1
  23. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/skills/panorama-super-cli/SKILL.md +13 -9
  24. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_cli.py +77 -0
  25. panorama_super_cli-0.2.6/tests/test_cli_profile.py +75 -0
  26. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_live_apply.py +63 -1
  27. panorama_super_cli-0.2.6/tests/test_output.py +65 -0
  28. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_source.py +36 -0
  29. panorama_super_cli-0.2.6/tests/test_version_check.py +108 -0
  30. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/uv.lock +3 -1
  31. panorama_super_cli-0.2.4/tests/test_output.py +0 -35
  32. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/CODEOWNERS +0 -0
  33. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  34. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  35. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  36. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/dependabot.yml +0 -0
  37. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/docs.yml +0 -0
  38. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/lint.yml +0 -0
  39. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/release.yml +0 -0
  40. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/test.yml +0 -0
  41. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.gitignore +0 -0
  42. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.pre-commit-config.yaml +0 -0
  43. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.python-version +0 -0
  44. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/AGENTS.md +0 -0
  45. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/CLAUDE.md +0 -0
  46. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/LICENSE +0 -0
  47. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/README.md +0 -0
  48. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/contributing/branching.md +0 -0
  49. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/contributing/development.md +0 -0
  50. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/contributing/release-process.md +0 -0
  51. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/getting-started/concepts.md +0 -0
  52. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/getting-started/first-run.md +0 -0
  53. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/duplicates-and-merging.md +0 -0
  54. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/finding-objects.md +0 -0
  55. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/references-and-audit.md +0 -0
  56. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/using-with-ai-agents.md +0 -0
  57. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/index.md +0 -0
  58. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/reference/config.md +0 -0
  59. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/reference/exit-codes.md +0 -0
  60. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/justfile +0 -0
  61. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/mkdocs.yml +0 -0
  62. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/__init__.py +0 -0
  63. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/__main__.py +0 -0
  64. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/__init__.py +0 -0
  65. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/auth_cmds.py +0 -0
  66. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/refs_cmds.py +0 -0
  67. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/config/__init__.py +0 -0
  68. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/config/models.py +0 -0
  69. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/__init__.py +0 -0
  70. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/apply_live.py +0 -0
  71. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/apply_xml.py +0 -0
  72. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/changeset.py +0 -0
  73. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/dedup.py +0 -0
  74. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/models.py +0 -0
  75. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/naming.py +0 -0
  76. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/normalize.py +0 -0
  77. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/parse.py +0 -0
  78. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/refs.py +0 -0
  79. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/resolve.py +0 -0
  80. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/setcmd.py +0 -0
  81. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/output/__init__.py +0 -0
  82. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/output/errors.py +0 -0
  83. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/scripts/sync_agents_md.py +0 -0
  84. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/__init__.py +0 -0
  85. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/conftest.py +0 -0
  86. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/fixtures/nested-device-groups.xml +0 -0
  87. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/fixtures/panorama-config.xml +0 -0
  88. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_apply_xml.py +0 -0
  89. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_auth.py +0 -0
  90. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_changeset.py +0 -0
  91. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_cli_auth.py +0 -0
  92. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_dedup.py +0 -0
  93. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_hardening.py +0 -0
  94. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_naming.py +0 -0
  95. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_nested_dg.py +0 -0
  96. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_normalize.py +0 -0
  97. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_parse.py +0 -0
  98. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_refs.py +0 -0
  99. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_resolve.py +0 -0
  100. {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_setcmd.py +0 -0
@@ -7,6 +7,45 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.2.6 — 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - **`psc version` and `psc version check`** (#33). `psc version` prints the
15
+ installed version (the format-aware equivalent of the `--version` flag, which
16
+ is kept); `psc version check` queries PyPI and reports whether a newer release
17
+ is available, exiting 0 either way. An unreachable PyPI surfaces as a typed
18
+ `transport` error rather than a stack trace.
19
+
20
+ ### Fixed
21
+
22
+ - **`psc profile list` now prints the config file's location** (#48). The path
23
+ is platform-dependent (notably different on Windows), so it was hard to find
24
+ where psc reads/writes profiles. The path is written to stderr — keeping the
25
+ machine-readable rows on stdout clean — and flagged `(not created yet)` when
26
+ the file is absent.
27
+ - **`--out` now writes the artifact even in a dry-run, and on a live profile**
28
+ (#47). Previously `dedup merge`/`name` silently ignored `--out` unless
29
+ `--apply` was passed, and ignored it entirely on a live profile — so
30
+ `dedup merge … -of set --out out.txt` against a live Panorama printed the plan
31
+ but wrote no file. `--out` is now treated as an artifact request (a `set`
32
+ script or rewritten `xml`): writing a user-named file never touches the source
33
+ export or the live candidate, so it is honoured regardless of `--apply` or
34
+ source. `--apply` still governs the live candidate push; combining
35
+ `--apply --out` on a live profile both pushes and saves the artifact. Offline
36
+ `--apply` still requires `--out`. The blocker gate holds on every path.
37
+
38
+ ## v0.2.5 — 2026-06-04
39
+
40
+ ### Fixed
41
+
42
+ - **`psc find ip` table output now separates each target's matches with a
43
+ horizontal rule** (#43). Resolving several targets at once (notably with
44
+ `--file`) previously printed every match as one undifferentiated block, so it
45
+ was hard to see where one target's matches ended and the next began. The
46
+ table now draws a rule between per-target blocks. Machine output formats
47
+ (json/jsonl/yaml/csv/set) are unchanged.
48
+
10
49
  ## v0.2.4 — 2026-06-04
11
50
 
12
51
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.4
3
+ Version: 0.2.6
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/
@@ -20,6 +20,7 @@ Classifier: Topic :: System :: Systems Administration
20
20
  Requires-Python: >=3.12
21
21
  Requires-Dist: click>=8.1
22
22
  Requires-Dist: defusedxml>=0.7
23
+ Requires-Dist: packaging>=23.0
23
24
  Requires-Dist: pan-os-python>=1.12
24
25
  Requires-Dist: platformdirs>=4.2
25
26
  Requires-Dist: pydantic>=2.7
@@ -24,10 +24,13 @@ command.
24
24
  Verify:
25
25
 
26
26
  ```console
27
- $ psc --version
27
+ $ psc version
28
28
  psc 0.1.0
29
29
  ```
30
30
 
31
+ (`psc --version` works too. `psc version check` reports whether a newer release
32
+ is available on PyPI.)
33
+
31
34
  `psc` requires Python 3.12+. It has no external service dependencies for the
32
35
  offline path; the live path talks to Panorama via
33
36
  [`pan-os-python`](https://github.com/PaloAltoNetworks/pan-os-python), which is
@@ -58,3 +58,8 @@ gate and repoint-before-delete ordering apply on the wire. See
58
58
  | Reads | ✅ | ✅ |
59
59
  | Writes | new file via `--out` | candidate config (you commit) |
60
60
  | Best for | audits, CI, safe edits | lookups and edits against prod |
61
+
62
+ `--out` works on either source — it saves a reviewable `set`/`xml` artifact
63
+ without touching the export or the candidate, even in a dry-run. On live it's
64
+ independent of `--apply`: use `--apply` to push the candidate, add `--out` if
65
+ you also want the script on disk.
@@ -40,7 +40,10 @@ psc -c panorama.xml name rename --object h-web1 --to H-10.0.0.10
40
40
  psc -c panorama.xml name apply --object h-web1 # rename to the scheme name
41
41
  ```
42
42
 
43
- Both are dry-run by default; add `--apply --out fixed.xml` (offline) to execute.
43
+ Both are dry-run by default. Offline, `--out fixed.xml` writes the rewritten
44
+ config (add `--apply` to execute; the file is written either way). Add
45
+ `-of set` for a PAN-OS `set` script instead. Live, `--apply` pushes the
46
+ candidate; add `--out` to also save the artifact.
44
47
 
45
48
  ## The shadow guard
46
49
 
@@ -56,18 +56,19 @@ These are two different knobs and it's worth keeping them straight:
56
56
 
57
57
  - **`-o set`** controls what a command prints to **stdout** — use it to read or
58
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`.
59
+ - **`-of` / `--output-format`** (mutating commands only) controls the format of
60
+ the **`--out` file artifact**: `xml` (default) rewrites the whole config to
61
+ load with `load config`, while `set` writes the same PAN-OS `set` script shown
62
+ above to that file — easier to read and to paste into a config session or
63
+ `load config partial`.
64
64
 
65
65
  ```console
66
- psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --apply --out plan.set -of set
66
+ psc -c panorama.xml dedup merge --keep h-web1 --remove web-primary --out plan.set -of set
67
67
  ```
68
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.
69
+ `-of` only shapes the `--out` file. `--out` writes that file whenever it's
70
+ given including in a dry-run, and on a live profile — because writing a file
71
+ never touches the source export or the device candidate.
71
72
 
72
73
  ## Errors
73
74
 
@@ -37,9 +37,11 @@ don't block.
37
37
 
38
38
  ## Offline apply never overwrites your export
39
39
 
40
- Offline, `--apply` requires `--out PATH` and writes the rewritten config there —
41
- it will refuse to write back over the source file. Your export stays pristine and
42
- the change is reviewable as a diff.
40
+ Offline, `--out PATH` writes the rewritten config there — it will refuse to
41
+ write back over the source file. Your export stays pristine and the change is
42
+ reviewable as a diff. `--out` is an artifact request, so it is honoured even in
43
+ a dry-run (writing a file is not a mutation); `--apply` alone still requires
44
+ `--out`.
43
45
 
44
46
  ```console
45
47
  psc -c panorama.xml dedup merge --keep a --remove b --apply --out fixed.xml
@@ -74,8 +76,9 @@ rejected up front (`input`, exit `2`) rather than sent malformed — rename it o
74
76
  apply that plan offline. If a write fails mid-plan, `psc` reports how far it got
75
77
  and leaves the uncommitted candidate for you to inspect or revert.
76
78
 
77
- Prefer to stage the change as a file instead? Plan with `-o set` (paste / `load
78
- config partial`) or plan offline with `--apply --out`.
79
+ Prefer to stage the change as a file instead? Add `--out plan.set -of set` (it
80
+ writes the artifact without pushing, even on a live profile), or print the
81
+ script to stdout with `-o set` (paste / `load config partial`).
79
82
 
80
83
  ## Debugging
81
84
 
@@ -18,12 +18,18 @@ These are **context** options — pass them *before* the subcommand:
18
18
  | `--version` | Print version and exit. |
19
19
 
20
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
25
- Panorama's candidate config and never commits. See
26
- [Writes and safety](../guides/safety.md).
21
+ the individual mutating commands and are passed *after* the command.
22
+
23
+ - `--out PATH` writes a reviewable artifact file; `-of xml|set` chooses what it
24
+ holds (default `xml`). It's an artifact request, not a mutation, so it's
25
+ honoured even in a dry-run and on a live profile — writing a file never
26
+ touches the source export or the device candidate.
27
+ - `--apply` executes the change against the managed config: **live** pushes the
28
+ plan to Panorama's candidate config and never commits; **offline** requires
29
+ `--out PATH` (the rewritten file *is* the execution, and never overwrites the
30
+ source export).
31
+
32
+ See [Writes and safety](../guides/safety.md).
27
33
 
28
34
  ## Commands
29
35
 
@@ -110,5 +116,18 @@ psc profile remove <name>
110
116
  ```
111
117
 
112
118
  Manage live connection profiles. `init`/`login` are the friendlier front door;
113
- `profile add` is the scriptable, non-interactive form. See
114
- [Configuration](config.md).
119
+ `profile add` is the scriptable, non-interactive form. `profile list` also
120
+ prints the config file's location (on stderr, so machine output stays clean)
121
+ handy because the path is platform-dependent. See [Configuration](config.md).
122
+
123
+ ### version
124
+
125
+ ```
126
+ psc version
127
+ psc version check
128
+ ```
129
+
130
+ `psc version` prints the installed version (the format-aware equivalent of the
131
+ `--version` flag). `psc version check` queries PyPI and reports whether a newer
132
+ release is available; it exits 0 either way and emits a typed `transport` error
133
+ if PyPI is unreachable.
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.2.4"
7
+ __version__ = "0.2.6"
@@ -2,7 +2,9 @@
2
2
 
3
3
  Centralising this is what guarantees the safety contract is identical across
4
4
  `dedup merge`, `name rename`, and any future write command: blocked plans are
5
- refused, dry-run is the default, and `--apply` is the only path to a write.
5
+ refused, dry-run is the default, and `--apply` is the only path to mutating the
6
+ managed config (the live candidate; offline, the rewritten `--out` file). A
7
+ bare `--out` only ever writes a reviewable artifact file — never the device.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -27,7 +29,7 @@ OUT_FORMAT_OPTION = typer.Option(
27
29
  "Format of the offline --out artifact. 'xml' (default) rewrites the whole "
28
30
  "config to load with `load config`; 'set' writes the equivalent PAN-OS set "
29
31
  "script (the creates/deletes/repoints) — easier to read and to paste into a "
30
- "config session. Only affects --out; ignored without --apply."
32
+ "config session. Only affects the --out file."
31
33
  ),
32
34
  )
33
35
 
@@ -64,7 +66,10 @@ def complete(
64
66
  _print_human_plan(rt, cs)
65
67
 
66
68
  if cs.is_empty:
67
- rt.stderr.print("[dim]nothing to do[/dim]")
69
+ # An empty plan has no artifact to write — say so explicitly when `--out`
70
+ # was asked for, rather than silently leaving the file absent.
71
+ note = " — no artifact written" if out_path is not None else ""
72
+ rt.stderr.print(f"[dim]nothing to do{note}[/dim]")
68
73
  return
69
74
 
70
75
  if apply:
@@ -73,6 +78,15 @@ def complete(
73
78
  f"[green]applied[/green] {result.ops} operation(s)"
74
79
  + (f" → {result.out_path}" if result.out_path else "")
75
80
  )
81
+ elif out_path is not None:
82
+ # `--out` is an artifact request, not a mutation: writing a user-named
83
+ # file never touches the source export or the live candidate, so honour
84
+ # it even in a dry-run. This is the whole fix for #47.
85
+ result = rt.source().write_out(cs, out_path=out_path, out_format=out_format)
86
+ rt.stderr.print(
87
+ f"[green]wrote[/green] {out_format.value} artifact → {result.out_path} "
88
+ "[dim](dry-run — re-run with --apply to push)[/dim]"
89
+ )
76
90
  else:
77
91
  rt.stderr.print("[yellow]dry-run[/yellow] — re-run with --apply to execute")
78
92
 
@@ -10,9 +10,17 @@ import typer
10
10
  from rich.console import Console
11
11
 
12
12
  from psc import __version__
13
- from psc.cli import auth_cmds, dedup_cmds, find_cmds, name_cmds, profile_cmds, refs_cmds
13
+ from psc.cli import (
14
+ auth_cmds,
15
+ dedup_cmds,
16
+ find_cmds,
17
+ name_cmds,
18
+ profile_cmds,
19
+ refs_cmds,
20
+ version_cmds,
21
+ )
14
22
  from psc.cli.runtime import Runtime, configure_logging
15
- from psc.config.loader import load_config
23
+ from psc.config.loader import config_path, load_config
16
24
  from psc.output.errors import PscError
17
25
  from psc.output.format import OutputFormat
18
26
 
@@ -109,14 +117,16 @@ def root(
109
117
  ) -> None:
110
118
  global _ACTIVE # noqa: PLW0603 — single process-wide handle for the error printer
111
119
  configure_logging(debug)
120
+ cfg_path = config_path() # resolve once so the loaded and displayed paths match
112
121
  rt = Runtime(
113
- config=load_config(),
122
+ config=load_config(cfg_path),
114
123
  config_file=config,
115
124
  profile=profile,
116
125
  debug=debug,
117
126
  device_group=device_group,
118
127
  strict=strict,
119
128
  _output=output,
129
+ config_path=cfg_path,
120
130
  )
121
131
  ctx.obj = rt
122
132
  _ACTIVE = rt
@@ -127,6 +137,7 @@ app.add_typer(dedup_cmds.app, name="dedup", help="Find and merge duplicate objec
127
137
  app.add_typer(refs_cmds.app, name="refs", help="Where-used, unused, and dangling references.")
128
138
  app.add_typer(name_cmds.app, name="name", help="Naming-template lint and rename.")
129
139
  app.add_typer(profile_cmds.app, name="profile", help="Manage live connection profiles.")
140
+ app.add_typer(version_cmds.app, name="version", help="Show version; check PyPI for updates.")
130
141
  app.command(
131
142
  "init",
132
143
  help="Interactively bootstrap a live profile (fetches an API key from a username/password).",
@@ -85,7 +85,10 @@ def merge(
85
85
  ),
86
86
  apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
87
87
  out: str | None = typer.Option(
88
- None, "--out", help="Offline: write the rewritten config here (see --output-format)."
88
+ None,
89
+ "--out",
90
+ help="Write the plan artifact (set script or rewritten config) to this "
91
+ "file; honoured even in a dry-run (see --output-format).",
89
92
  ),
90
93
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
91
94
  ) -> None:
@@ -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")
@@ -51,7 +51,10 @@ def rename(
51
51
  location: str | None = typer.Option(None, "--location"),
52
52
  apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
53
53
  out: str | None = typer.Option(
54
- None, "--out", help="Offline: write rewritten config here (see --output-format)."
54
+ None,
55
+ "--out",
56
+ help="Write the plan artifact (set script or rewritten config) to this "
57
+ "file; honoured even in a dry-run (see --output-format).",
55
58
  ),
56
59
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
57
60
  ) -> None:
@@ -71,7 +74,10 @@ def apply_scheme(
71
74
  location: str | None = typer.Option(None, "--location"),
72
75
  apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
73
76
  out: str | None = typer.Option(
74
- None, "--out", help="Offline: write rewritten config here (see --output-format)."
77
+ None,
78
+ "--out",
79
+ help="Write the plan artifact (set script or rewritten config) to this "
80
+ "file; honoured even in a dry-run (see --output-format).",
75
81
  ),
76
82
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
77
83
  ) -> None:
@@ -17,6 +17,16 @@ app = typer.Typer(no_args_is_help=True)
17
17
  def list_profiles(ctx: typer.Context) -> None:
18
18
  """Show configured live profiles (API keys are not printed)."""
19
19
  rt: Runtime = ctx.obj
20
+ # Where the config lives is platform-dependent (it differs on Windows), so
21
+ # surface it to help users find/edit the file (#48). It goes to stderr so it
22
+ # never pollutes the machine-readable rows on stdout, and prints even when
23
+ # no profiles are configured yet — the empty case is exactly when "where do
24
+ # I put them?" matters most.
25
+ path = rt.config_path
26
+ suffix = "" if path.exists() else " (not created yet)"
27
+ # soft_wrap + markup=False: never wrap a long path onto two lines, and never
28
+ # let a path with `[...]` be parsed as rich markup.
29
+ rt.stderr.print(f"config file: {path}{suffix}", soft_wrap=True, markup=False, highlight=False)
20
30
  rows = [
21
31
  {
22
32
  "name": p.name,
@@ -10,10 +10,12 @@ from __future__ import annotations
10
10
 
11
11
  import sys
12
12
  from dataclasses import dataclass, field
13
+ from pathlib import Path
13
14
 
14
15
  import structlog
15
16
  from rich.console import Console
16
17
 
18
+ from psc.config.loader import config_path
17
19
  from psc.config.models import Config
18
20
  from psc.core.models import Location, Snapshot
19
21
  from psc.core.source import LiveSource, OfflineSource
@@ -44,6 +46,10 @@ class Runtime:
44
46
  device_group: str | None
45
47
  strict: bool
46
48
  _output: OutputFormat | None
49
+ # The resolved `config.yaml` path actually loaded from — so commands can show
50
+ # users where their profiles live (#48). Distinct from `config_file`, which
51
+ # is the offline `--config` XML export.
52
+ config_path: Path = field(default_factory=config_path)
47
53
  stdout: Console = field(default_factory=Console)
48
54
  stderr: Console = field(default_factory=lambda: Console(stderr=True))
49
55
  _source: OfflineSource | LiveSource | None = None
@@ -0,0 +1,42 @@
1
+ """`psc version` — show the installed version and check PyPI for updates (#33).
2
+
3
+ `psc version` (bare) replaces `psc --version` with a format-aware report; the
4
+ `--version` flag stays for backwards compatibility. `psc version check` reaches
5
+ out to PyPI and tells you whether a newer release is available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from psc import __version__
13
+ from psc.cli.runtime import Runtime
14
+ from psc.core.version_check import check_for_update
15
+ from psc.output.format import render
16
+
17
+ app = typer.Typer(no_args_is_help=False)
18
+
19
+
20
+ @app.callback(invoke_without_command=True)
21
+ def version(ctx: typer.Context) -> None:
22
+ """Show the installed psc version."""
23
+ if ctx.invoked_subcommand is not None:
24
+ return
25
+ rt: Runtime = ctx.obj
26
+ row = {"version": __version__}
27
+ render(rt.stdout, rt.output, model=row, rows=[row], table_title="version")
28
+
29
+
30
+ @app.command("check")
31
+ def check(ctx: typer.Context) -> None:
32
+ """Check PyPI for a newer release of panorama-super-cli."""
33
+ rt: Runtime = ctx.obj
34
+ info = check_for_update()
35
+ render(rt.stdout, rt.output, model=info, rows=[info.model_dump()], table_title="update check")
36
+ if info.update_available:
37
+ rt.stderr.print(
38
+ f"[yellow]update available[/yellow]: {info.installed} → {info.latest} "
39
+ "(run: pip install -U panorama-super-cli)"
40
+ )
41
+ else:
42
+ rt.stderr.print(f"[green]up to date[/green]: {info.installed}")
@@ -22,8 +22,10 @@ def config_path() -> Path:
22
22
  return Path(user_config_dir(_APP)) / "config.yaml"
23
23
 
24
24
 
25
- def load_config() -> Config:
26
- path = config_path()
25
+ def load_config(path: Path | None = None) -> Config:
26
+ # Callers may pass an already-resolved path so the value they display (e.g.
27
+ # `psc profile list`) is provably the same file we loaded (#48).
28
+ path = path if path is not None else config_path()
27
29
  if not path.exists():
28
30
  return Config()
29
31
  yaml = YAML(typ="safe")
@@ -75,6 +75,17 @@ def _ssl_context(verify: bool) -> ssl.SSLContext:
75
75
  return ctx
76
76
 
77
77
 
78
+ def _write_artifact(out: Path, data: str) -> None:
79
+ """Write an `--out` artifact, mapping filesystem errors onto the error
80
+ contract. A bad `--out` (missing parent dir, a directory, unwritable) is an
81
+ *expected* failure — surface it as `INPUT` rather than leaking a raw
82
+ `OSError` traceback (which would also corrupt machine output)."""
83
+ try:
84
+ out.write_text(data, encoding="utf-8")
85
+ except OSError as exc:
86
+ raise PscError(f"cannot write artifact to {out}: {exc}", ErrorType.INPUT) from exc
87
+
88
+
78
89
  class OfflineSource:
79
90
  """Read + rewrite an exported Panorama config XML on disk."""
80
91
 
@@ -98,16 +109,23 @@ class OfflineSource:
98
109
  except Exception as exc:
99
110
  raise PscError(f"failed to parse {self.path}: {exc}", ErrorType.INPUT) from exc
100
111
 
101
- def apply(
112
+ def write_out(
102
113
  self,
103
114
  cs: ChangeSet,
104
115
  *,
105
116
  out_path: str | Path | None,
106
117
  out_format: ConfigFormat = ConfigFormat.XML,
107
118
  ) -> ApplyResult:
119
+ """Render `cs` to the `--out` artifact file (set script or rewritten XML).
120
+
121
+ This is the *artifact* path: it only ever writes the separate `--out`
122
+ file and never the source export, so it is safe in a dry-run. For the
123
+ offline source the artifact *is* the whole point — `apply` is just this
124
+ with an `applied=True` flag (there is no separate device to push to).
125
+ """
108
126
  if out_path is None:
109
127
  raise PscError(
110
- "offline apply needs --out PATH (the rewritten config is never "
128
+ "offline --out needs a PATH (the rewritten config is never "
111
129
  "written back over the source export)",
112
130
  ErrorType.CONFIG,
113
131
  )
@@ -119,18 +137,30 @@ class OfflineSource:
119
137
  # it, so refuse before writing rather than emit a `# BLOCKED` file.
120
138
  if cs.is_blocked:
121
139
  raise PscError(
122
- "refusing to apply a blocked plan",
140
+ "refusing to write a blocked plan",
123
141
  ErrorType.CONFLICT,
124
142
  details={"blockers": cs.blockers},
125
143
  )
126
144
  # Both artifacts share the same safety guards above; only the bytes differ.
127
145
  if out_format is ConfigFormat.SET:
128
146
  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)
147
+ _write_artifact(out, "\n".join(script) + "\n")
148
+ return ApplyResult(applied=False, ops=cs.op_count, out_path=str(out), set_script=script)
131
149
  new_xml = apply_changeset(self._xml, cs)
132
- out.write_text(new_xml, encoding="utf-8")
133
- return ApplyResult(applied=True, ops=cs.op_count, out_path=str(out))
150
+ _write_artifact(out, new_xml)
151
+ return ApplyResult(applied=False, ops=cs.op_count, out_path=str(out))
152
+
153
+ def apply(
154
+ self,
155
+ cs: ChangeSet,
156
+ *,
157
+ out_path: str | Path | None,
158
+ out_format: ConfigFormat = ConfigFormat.XML,
159
+ ) -> ApplyResult:
160
+ # Offline has no live device: applying *is* producing the `--out`
161
+ # artifact. Reuse the artifact path and only flip the `applied` flag.
162
+ res = self.write_out(cs, out_path=out_path, out_format=out_format)
163
+ return res.model_copy(update={"applied": True})
134
164
 
135
165
 
136
166
  class LiveSource:
@@ -151,6 +181,7 @@ class LiveSource:
151
181
  self._api_key = api_key
152
182
  self._port = port
153
183
  self._verify = verify
184
+ self._raw: str | None = None
154
185
 
155
186
  @staticmethod
156
187
  def fetch_api_key(
@@ -242,10 +273,17 @@ class LiveSource:
242
273
  return pano
243
274
 
244
275
  def raw_xml(self) -> str:
276
+ # Memoised for the lifetime of this source: one CLI invocation reads the
277
+ # running config once and reuses it for the snapshot *and* any `--out`
278
+ # XML artifact, so the artifact is rewritten from the exact config the
279
+ # plan was computed against (no divergence, no second round-trip).
280
+ if self._raw is not None:
281
+ return self._raw
245
282
  try:
246
283
  pano = self._device()
247
284
  pano.xapi.show(xpath="/config") # type: ignore[attr-defined]
248
- return str(pano.xapi.xml_result()) # type: ignore[attr-defined]
285
+ self._raw = str(pano.xapi.xml_result()) # type: ignore[attr-defined]
286
+ return self._raw
249
287
  except PscError:
250
288
  raise
251
289
  except Exception as exc:
@@ -256,6 +294,40 @@ class LiveSource:
256
294
  def snapshot(self) -> Snapshot:
257
295
  return parse_config(self.raw_xml())
258
296
 
297
+ def write_out(
298
+ self,
299
+ cs: ChangeSet,
300
+ *,
301
+ out_path: str | Path | None,
302
+ out_format: ConfigFormat = ConfigFormat.XML,
303
+ ) -> ApplyResult:
304
+ """Render `cs` to the `--out` artifact file from the live config.
305
+
306
+ This never touches the device's candidate config — it only writes a
307
+ local file the operator can review or paste. A `set` script is pure
308
+ rendering of the plan; an `xml` artifact is the *running* config (read
309
+ over the API) rewritten by the same offline applier. Honoured in a
310
+ dry-run because emitting a file is not a device mutation.
311
+ """
312
+ if out_path is None:
313
+ raise PscError("--out needs a PATH", ErrorType.CONFIG)
314
+ out = Path(out_path)
315
+ # Same blocker gate as every other write path: refuse before any bytes
316
+ # hit disk rather than leave a misleading artifact.
317
+ if cs.is_blocked:
318
+ raise PscError(
319
+ "refusing to write a blocked plan",
320
+ ErrorType.CONFLICT,
321
+ details={"blockers": cs.blockers},
322
+ )
323
+ if out_format is ConfigFormat.SET:
324
+ script = render_changeset(cs)
325
+ _write_artifact(out, "\n".join(script) + "\n")
326
+ return ApplyResult(applied=False, ops=cs.op_count, out_path=str(out), set_script=script)
327
+ new_xml = apply_changeset(self.raw_xml(), cs)
328
+ _write_artifact(out, new_xml)
329
+ return ApplyResult(applied=False, ops=cs.op_count, out_path=str(out))
330
+
259
331
  def apply(
260
332
  self,
261
333
  cs: ChangeSet,
@@ -265,9 +337,9 @@ class LiveSource:
265
337
  ) -> ApplyResult:
266
338
  """Push `cs` to Panorama's candidate config over the XML API.
267
339
 
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.
340
+ When `out_path` is given, the `--out` artifact is written *first* (so a
341
+ reviewable file survives even if the device push later fails midway),
342
+ then the plan is pushed to the candidate.
271
343
 
272
344
  The plan is lowered to xpath set/edit/delete/rename ops and replayed in
273
345
  order (repoint before delete). The `blockers` gate and name-addressing
@@ -286,6 +358,13 @@ class LiveSource:
286
358
  # live update) before we touch the device.
287
359
  ops = plan_xapi_ops(cs)
288
360
 
361
+ # Write the reviewable artifact first so it survives a failed push.
362
+ artifact = (
363
+ self.write_out(cs, out_path=out_path, out_format=out_format)
364
+ if out_path is not None
365
+ else None
366
+ )
367
+
289
368
  from panos.errors import PanConnectionTimeout, PanURLError # noqa: PLC0415
290
369
 
291
370
  pano = self._device()
@@ -323,4 +402,9 @@ class LiveSource:
323
402
  ErrorType.TRANSPORT,
324
403
  details={"sent": sent, "planned": len(ops)},
325
404
  ) from exc
326
- return ApplyResult(applied=True, ops=len(ops), out_path=None)
405
+ return ApplyResult(
406
+ applied=True,
407
+ ops=len(ops),
408
+ out_path=artifact.out_path if artifact else None,
409
+ set_script=artifact.set_script if artifact else [],
410
+ )