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.
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/CHANGELOG.md +39 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/PKG-INFO +2 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/getting-started/install.md +4 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/live-vs-offline.md +5 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/naming.md +4 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/output-formats.md +9 -8
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/safety.md +8 -5
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/reference/cli.md +27 -8
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/_version.py +1 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/_plan.py +17 -3
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/app.py +14 -3
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/dedup_cmds.py +4 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/find_cmds.py +3 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/name_cmds.py +8 -2
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/profile_cmds.py +10 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/runtime.py +6 -0
- panorama_super_cli-0.2.6/psc/cli/version_cmds.py +42 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/config/loader.py +4 -2
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/source.py +96 -12
- panorama_super_cli-0.2.6/psc/core/version_check.py +66 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/output/format.py +11 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/pyproject.toml +2 -1
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/skills/panorama-super-cli/SKILL.md +13 -9
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_cli.py +77 -0
- panorama_super_cli-0.2.6/tests/test_cli_profile.py +75 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_live_apply.py +63 -1
- panorama_super_cli-0.2.6/tests/test_output.py +65 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_source.py +36 -0
- panorama_super_cli-0.2.6/tests/test_version_check.py +108 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/uv.lock +3 -1
- panorama_super_cli-0.2.4/tests/test_output.py +0 -35
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/CODEOWNERS +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/dependabot.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/docs.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/lint.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/release.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.github/workflows/test.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.gitignore +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.pre-commit-config.yaml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/.python-version +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/AGENTS.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/CLAUDE.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/LICENSE +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/README.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/contributing/branching.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/contributing/development.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/contributing/release-process.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/getting-started/concepts.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/getting-started/first-run.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/duplicates-and-merging.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/finding-objects.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/references-and-audit.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/guides/using-with-ai-agents.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/index.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/reference/config.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/docs/reference/exit-codes.md +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/justfile +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/mkdocs.yml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/__init__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/__main__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/__init__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/auth_cmds.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/cli/refs_cmds.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/config/__init__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/config/models.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/__init__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/apply_live.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/apply_xml.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/changeset.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/dedup.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/models.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/naming.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/normalize.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/parse.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/refs.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/resolve.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/core/setcmd.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/output/__init__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/psc/output/errors.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/scripts/sync_agents_md.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/__init__.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/conftest.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/fixtures/nested-device-groups.xml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/fixtures/panorama-config.xml +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_apply_xml.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_auth.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_changeset.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_cli_auth.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_dedup.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_hardening.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_naming.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_nested_dg.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_normalize.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_parse.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_refs.py +0 -0
- {panorama_super_cli-0.2.4 → panorama_super_cli-0.2.6}/tests/test_resolve.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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`** (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 --
|
|
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
|
|
70
|
-
|
|
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, `--
|
|
41
|
-
|
|
42
|
-
|
|
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?
|
|
78
|
-
|
|
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.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
114
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
130
|
-
return ApplyResult(applied=
|
|
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
|
|
133
|
-
return ApplyResult(applied=
|
|
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
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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(
|
|
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
|
+
)
|