vcf-super-cli 0.3.0__tar.gz → 0.4.0__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. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/CHANGELOG.md +29 -0
  2. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/PKG-INFO +1 -1
  3. vcf_super_cli-0.4.0/docs/design.md +89 -0
  4. vcf_super_cli-0.4.0/docs/index.md +46 -0
  5. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/usage.md +29 -2
  6. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/pyproject.toml +1 -1
  7. vcf_super_cli-0.4.0/tests/test_complete_cache.py +107 -0
  8. vcf_super_cli-0.4.0/tests/test_complete_dynamic.py +205 -0
  9. vcf_super_cli-0.4.0/tests/test_resources.py +115 -0
  10. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/uv.lock +1 -1
  11. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/_version.py +1 -1
  12. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/builder.py +20 -3
  13. vcf_super_cli-0.4.0/vsc/gen/complete_cache.py +128 -0
  14. vcf_super_cli-0.4.0/vsc/gen/complete_dynamic.py +145 -0
  15. vcf_super_cli-0.4.0/vsc/gen/resources.py +135 -0
  16. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/skill/assets/SKILL.md +5 -3
  17. vcf_super_cli-0.3.0/docs/design.md +0 -31
  18. vcf_super_cli-0.3.0/docs/index.md +0 -38
  19. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/.github/workflows/docs.yml +0 -0
  20. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/.github/workflows/e2e.yml +0 -0
  21. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/.github/workflows/lint.yml +0 -0
  22. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/.github/workflows/release.yml +0 -0
  23. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/.github/workflows/test.yml +0 -0
  24. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/.gitignore +0 -0
  25. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/AGENTS.md +0 -0
  26. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/LICENSE +0 -0
  27. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/README.md +0 -0
  28. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/commands.md +0 -0
  29. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/install.md +0 -0
  30. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/profiles.md +0 -0
  31. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/superpowers/specs/2026-06-05-vcf-super-cli-design.md +0 -0
  32. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.2-writes-design.md +0 -0
  33. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.3-ergonomics-design.md +0 -0
  34. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/docs/writes.md +0 -0
  35. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/mkdocs.yml +0 -0
  36. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/__init__.py +0 -0
  37. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/e2e/README.md +0 -0
  38. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/e2e/__init__.py +0 -0
  39. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/e2e/conftest.py +0 -0
  40. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/e2e/test_read_smoke.py +0 -0
  41. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_builder.py +0 -0
  42. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_cli.py +0 -0
  43. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_complete.py +0 -0
  44. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_config.py +0 -0
  45. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_discover.py +0 -0
  46. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_errors.py +0 -0
  47. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_filter_pagination_cli.py +0 -0
  48. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_filters.py +0 -0
  49. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_paginate.py +0 -0
  50. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_params.py +0 -0
  51. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_preview.py +0 -0
  52. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_pyvmomi_events.py +0 -0
  53. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_pyvmomi_inventory.py +0 -0
  54. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_pyvmomi_perf.py +0 -0
  55. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_pyvmomi_tasks.py +0 -0
  56. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_render.py +0 -0
  57. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_session.py +0 -0
  58. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_skill.py +0 -0
  59. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_version.py +0 -0
  60. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_vmomi.py +0 -0
  61. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/tests/test_write_errors.py +0 -0
  62. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/__init__.py +0 -0
  63. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/cli/__init__.py +0 -0
  64. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/cli/app.py +0 -0
  65. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/cli/profiles.py +0 -0
  66. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/cli/skill.py +0 -0
  67. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/config/__init__.py +0 -0
  68. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/config/schema.py +0 -0
  69. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/config/store.py +0 -0
  70. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/connect/__init__.py +0 -0
  71. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/connect/session.py +0 -0
  72. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/connect/targets.py +0 -0
  73. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/connect/vmomi.py +0 -0
  74. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/__init__.py +0 -0
  75. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/complete.py +0 -0
  76. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/discover.py +0 -0
  77. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/filters.py +0 -0
  78. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/model.py +0 -0
  79. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/paginate.py +0 -0
  80. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/params.py +0 -0
  81. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/gen/preview.py +0 -0
  82. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/logging_config.py +0 -0
  83. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/output/__init__.py +0 -0
  84. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/output/errors.py +0 -0
  85. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/output/exit_codes.py +0 -0
  86. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/output/render.py +0 -0
  87. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/pyvmomi/__init__.py +0 -0
  88. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/pyvmomi/events.py +0 -0
  89. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/pyvmomi/inventory.py +0 -0
  90. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/pyvmomi/perf.py +0 -0
  91. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/pyvmomi/runner.py +0 -0
  92. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/pyvmomi/tasks.py +0 -0
  93. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/skill/__init__.py +0 -0
  94. {vcf_super_cli-0.3.0 → vcf_super_cli-0.4.0}/vsc/skill/export.py +0 -0
@@ -7,6 +7,35 @@ versions may include breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.4.0 — 2026-06-08
11
+
12
+ Live resource-id completion. Pressing `<TAB>` on an id-typed argument can now
13
+ suggest **real ids** from the live inventory — strictly opt-in, cached, and
14
+ fail-soft. The agent-facing contract is unchanged: stable JSON, error envelope,
15
+ exit codes, dry-run-by-default writes, and `--help` stays fully offline.
16
+
17
+ ### Added
18
+
19
+ - **Live resource-id completion** (#44), opt-in via `VSC_COMPLETE_DYNAMIC=1` and
20
+ off by default. When enabled, `<TAB>` on an id arg/option suggests live ids for
21
+ the resource type (VMs, hosts, clusters, datacenters, datastores, resource
22
+ pools), with each resource's name shown as completion help. Built on:
23
+ - a resource-type → list-op **registry** derived purely by introspecting the
24
+ SDK metadata (#45), no network;
25
+ - a **TTL cache** under the platform cache dir, keyed by
26
+ profile/backend/resource-type (default 60s, `VSC_COMPLETE_TTL` override) (#46);
27
+ - a **dynamic completer** whose fetch is time-bounded (`VSC_COMPLETE_TIMEOUT`,
28
+ default 2s) and blanket fail-soft — any error, missing auth, or timeout
29
+ yields no suggestions and is never cached (#47).
30
+ - Static/offline completion (enums, output formats, profiles, filter enums) is
31
+ unchanged and remains the default. `--help` and command execution never open a
32
+ connection for completion.
33
+
34
+ ### Notes
35
+
36
+ - Live completion is a human convenience only; agents should keep using `list`
37
+ to discover ids and must not depend on completion for correctness (#48).
38
+
10
39
  ## v0.3.0 — 2026-06-05
11
40
 
12
41
  Ergonomics: friendlier filtering and paging, offline shell completion, and a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vcf-super-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Modern, agent-friendly CLI for VMware Cloud Foundation 9, with a command tree generated dynamically from the vcf-sdk vAPI bindings.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/vcf-super-cli
6
6
  Project-URL: Documentation, https://thomaschristory.github.io/vcf-super-cli/
@@ -0,0 +1,89 @@
1
+ # Design
2
+
3
+ `vsc` builds its command tree by **statically introspecting the installed
4
+ `vcf-sdk` vAPI bindings**. Both `vmware-vcenter` and `vcf-nsx` are vAPI stub
5
+ libraries on the shared `vmware-vapi-runtime`:
6
+
7
+ - Each service is a `VapiInterface` subclass (`VM`, `Host`, `Cluster`,
8
+ `Datastore`, `Network`, …).
9
+ - Every operation carries `OperationRestMetadata` (`http_method`,
10
+ `path_variables`, `query_parameters`).
11
+ - Parameters are typed via `StructType` binding types.
12
+
13
+ A single generator walks those classes and emits one Typer command per
14
+ operation. `http_method == "GET"` ⇒ a read command; other verbs ⇒ writes
15
+ (dry-run by default + `--apply`). Because the metadata ships inside the SDK, the
16
+ tree builds **offline** — no server needed to render `--help`.
17
+
18
+ Top-level grouping:
19
+
20
+ - `vsc vsphere …` → `com.vmware.vcenter` (+ selected `com.vmware.esx`)
21
+ - `vsc nsx …` → `vcf.nsx.policy` (NSX Policy API)
22
+
23
+ The introspected intermediate representation is a list of `Operation` objects,
24
+ each carrying typed `Param`s (`vsc/gen/model.py`). Everything below is built from
25
+ that one model.
26
+
27
+ ## Ergonomics (v0.3)
28
+
29
+ These build on the introspected `Param` model without changing the agent
30
+ contract.
31
+
32
+ ### Offline shell completion
33
+
34
+ Completion values come entirely from the model and local config — **never a
35
+ network call**, so `<TAB>` stays fast and `--help` stays offline. Enum options
36
+ complete from their fixed choices, `--output` from the output formats, and
37
+ `--profile` from configured profile names (`vsc/gen/complete.py`). Completing a
38
+ live resource id would require a connection and is deferred to a later release.
39
+
40
+ ### Per-field filter flags
41
+
42
+ A `list` operation takes a single `filter` parameter that is a struct
43
+ (`VM.FilterSpec` etc.). The generator flattens that struct into typed
44
+ `--<field>` options (repeatable for list/set fields; enum fields validate and
45
+ complete their choices). The raw `--filter '<json>'` blob remains as a base
46
+ layer that per-field flags merge **over** (`vsc/gen/filters.py`).
47
+
48
+ ### Pagination
49
+
50
+ `list` commands gain `--all` (follow the NSX cursor across pages), `--max-items`
51
+ (cap the total), and `--limit` (client-side cap for non-paginated vSphere
52
+ lists). Without `--all`, a paginated list returns one page and surfaces its
53
+ `cursor` for manual paging; on non-cursor backends `--all` is a no-op and the
54
+ output stays a plain array (`vsc/gen/paginate.py`).
55
+
56
+ ## pyVmomi fallback (v0.3)
57
+
58
+ A few inventory/performance areas are only reachable through the older pyVmomi
59
+ SOAP API. Those are **hand-written read-only commands** mounted under
60
+ `vsc vsphere` alongside the generated ones, sharing the same output contract:
61
+
62
+ - a separate connection path, `connect_vmomi()` via `SmartConnect`, reusing the
63
+ resolved vSphere credentials and honouring `--insecure` like the REST path
64
+ (`vsc/connect/vmomi.py`);
65
+ - `vmomi_jsonable()` collapses managed objects to their moref and data objects to
66
+ dicts so results render like any other;
67
+ - a shared `run_read()` runner maps pyVmomi faults onto the same error envelope
68
+ and exit codes (`vsc/pyvmomi/runner.py`).
69
+
70
+ Commands: `perf` (PerformanceManager counters), `events` / `tasks`
71
+ (Event/Task managers), and `inventory` (a PropertyCollector property walk). All
72
+ are reads — no `--apply`.
73
+
74
+ ## Design specs
75
+
76
+ The authoritative, milestone-by-milestone designs live in the repository:
77
+
78
+ - [v0.1 — dynamic read-only CLI](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-05-vcf-super-cli-design.md)
79
+ - [v0.2 — writes](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.2-writes-design.md)
80
+ - [v0.3 — ergonomics](https://github.com/thomaschristory/vcf-super-cli/blob/main/docs/superpowers/specs/2026-06-05-vcf-super-cli-v0.3-ergonomics-design.md)
81
+
82
+ ## Contracts
83
+
84
+ - **Output:** JSON by default; `--output table` for humans.
85
+ - **Errors:** stable envelope `{ "error": { code, message, kind, details } }`.
86
+ - **Exit codes:** documented `IntEnum` (`0` ok, `1` generic, `2` usage, `3` auth,
87
+ `4` not-found, `5` connection, `6` config, `7` conflict, `8` unavailable).
88
+ - **Writes:** dry-run by default; `--apply` is the only gate and a dry-run opens
89
+ no connection.
@@ -0,0 +1,46 @@
1
+ # vcf-super-cli
2
+
3
+ A modern, agent-friendly CLI for **VMware Cloud Foundation 9** whose command tree
4
+ is **generated dynamically** from the official [`vcf-sdk`](https://pypi.org/project/vcf-sdk/)
5
+ vAPI bindings.
6
+
7
+ ```console
8
+ $ vsc vsphere vm list --power-states POWERED_ON --profile prod
9
+ $ vsc nsx segments list --all --output table
10
+ $ vsc vsphere perf vm vm-42 --metric cpu.usage
11
+ ```
12
+
13
+ !!! warning "Pre-1.0"
14
+ Reads and writes are both available. **Writes are dry-run by default** —
15
+ nothing changes without `--apply`. See [Writes](writes.md). While on `0.x`,
16
+ minor versions may include breaking changes.
17
+
18
+ ## Highlights
19
+
20
+ - **Mirrors the real API** — commands come from the SDK's vAPI metadata, covering
21
+ vCenter and NSX from one generator.
22
+ - **Ergonomic** — offline tab-completion (enums, formats, profiles, filter
23
+ choices), per-field `--<field>` filter flags, and paging (`--all` /
24
+ `--max-items` / `--limit`).
25
+ - **pyVmomi fallback** — read-only `perf`, `events`, `tasks`, and `inventory`
26
+ commands for areas the REST/vAPI surface doesn't cover.
27
+ - **Safe by default** — writes are dry-run unless `--apply`; a dry-run never connects.
28
+ - **Agent-friendly** — JSON output, stable error envelope, documented exit codes,
29
+ bundled agent Skill.
30
+
31
+ See the [Design](design.md) for how dynamic generation works, and
32
+ [Commands](commands.md) for the full surface.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ uv tool install vcf-super-cli # or: pip install vcf-super-cli
38
+ vsc --install-completion # optional: offline shell completion
39
+ ```
40
+
41
+ From source:
42
+
43
+ ```sh
44
+ uv sync
45
+ uv run vsc --help
46
+ ```
@@ -30,8 +30,35 @@ Completion is **fully offline** — it never opens a connection. It suggests:
30
30
  - configured profile names (`--profile <TAB>`),
31
31
  - and per-field `list` filter enum choices.
32
32
 
33
- Completing a live resource id (e.g. `<vm>` from a real inventory) would require a
34
- network call and is deliberately **not** done in this release.
33
+ ### Live resource-id completion (opt-in)
34
+
35
+ Completing a real id (e.g. `<vm>` from the live inventory) does require a network
36
+ call, so it is **opt-in** and off by default:
37
+
38
+ ```sh
39
+ export VSC_COMPLETE_DYNAMIC=1
40
+ vsc vsphere vm get <TAB> # → vm-101 vm-102 … (real ids, names as help)
41
+ ```
42
+
43
+ When enabled, pressing `<TAB>` on an id-typed argument or option suggests live
44
+ ids for that resource type (VMs, hosts, clusters, datacenters, datastores,
45
+ resource pools), showing each resource's name as completion help.
46
+
47
+ It is built to never get in your way:
48
+
49
+ - **Off by default.** Without `VSC_COMPLETE_DYNAMIC`, `<TAB>` stays fully offline
50
+ (the suggestions above are all you get).
51
+ - **Cached.** Results are cached per profile/backend/resource-type under the
52
+ platform cache dir with a short TTL (default 60s; override with
53
+ `VSC_COMPLETE_TTL=<seconds>`), so repeated presses don't re-hit the API.
54
+ - **Bounded and fail-soft.** The fetch is abandoned after a short timeout
55
+ (`VSC_COMPLETE_TIMEOUT`, default 2s); any error, missing auth, or timeout
56
+ yields no suggestions — `<TAB>` never hangs or prints a traceback.
57
+ - **`--help` is always offline.** Only the shell-completion subprocess ever
58
+ fetches; `--help` and command execution are unaffected.
59
+
60
+ This is a convenience only. The agent contract is unchanged: don't rely on
61
+ completion for correctness — list commands remain the source of truth for ids.
35
62
 
36
63
  ## Error envelope
37
64
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vcf-super-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Modern, agent-friendly CLI for VMware Cloud Foundation 9, with a command tree generated dynamically from the vcf-sdk vAPI bindings."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,107 @@
1
+ """On-disk TTL cache for live completion candidates.
2
+
3
+ The cache keeps ``<TAB>`` fast and stops repeated completions from re-hitting the
4
+ API. It is fail-soft by contract: a missing, corrupt or unwritable cache is a
5
+ miss, never an exception — completion must never error or hang.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from vsc.gen.complete_cache import cache_dir, cache_ttl, get_or_fetch
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _tmp_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
20
+ monkeypatch.setenv("VSC_CACHE_DIR", str(tmp_path))
21
+ monkeypatch.delenv("VSC_COMPLETE_TTL", raising=False)
22
+
23
+
24
+ def test_hit_within_ttl_skips_fetch() -> None:
25
+ calls = 0
26
+
27
+ def fetch() -> list[tuple[str, str]]:
28
+ nonlocal calls
29
+ calls += 1
30
+ return [("vm-1", "web"), ("vm-2", "db")]
31
+
32
+ first = get_or_fetch("k", ttl=60.0, fetch_fn=fetch, now=1000.0)
33
+ second = get_or_fetch("k", ttl=60.0, fetch_fn=fetch, now=1059.0)
34
+ assert first == [("vm-1", "web"), ("vm-2", "db")]
35
+ assert second == first
36
+ assert calls == 1 # served from cache the second time
37
+
38
+
39
+ def test_expired_entry_refetches() -> None:
40
+ calls = 0
41
+
42
+ def fetch() -> list[tuple[str, str]]:
43
+ nonlocal calls
44
+ calls += 1
45
+ return [(f"vm-{calls}", "x")]
46
+
47
+ get_or_fetch("k", ttl=60.0, fetch_fn=fetch, now=1000.0)
48
+ again = get_or_fetch("k", ttl=60.0, fetch_fn=fetch, now=1061.0)
49
+ assert calls == 2
50
+ assert again == [("vm-2", "x")]
51
+
52
+
53
+ def test_corrupt_file_is_a_miss() -> None:
54
+ # Pre-create a garbage cache file at the key's path; get_or_fetch must treat
55
+ # it as a miss and never raise.
56
+ get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("a", "a")], now=0.0)
57
+ files = list(cache_dir().glob("*.json"))
58
+ assert files
59
+ files[0].write_text("}{ not json", encoding="utf-8")
60
+ out = get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("b", "b")], now=1.0)
61
+ assert out == [("b", "b")]
62
+
63
+
64
+ def test_cache_dir_created_lazily() -> None:
65
+ assert not cache_dir().exists()
66
+ get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("a", "a")], now=0.0)
67
+ assert cache_dir().exists()
68
+
69
+
70
+ def test_write_failure_is_failsoft(monkeypatch: pytest.MonkeyPatch) -> None:
71
+ def boom(*_a: object, **_k: object) -> None:
72
+ raise OSError("disk full")
73
+
74
+ monkeypatch.setattr("vsc.gen.complete_cache.os.replace", boom)
75
+ out = get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("a", "n")], now=0.0)
76
+ assert out == [("a", "n")] # fetched value still returned despite write failure
77
+
78
+
79
+ def test_items_round_trip_as_tuples() -> None:
80
+ get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("vm-1", "web")], now=0.0)
81
+ out = get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("zzz", "zzz")], now=1.0)
82
+ assert out == [("vm-1", "web")]
83
+ assert all(isinstance(item, tuple) and len(item) == 2 for item in out)
84
+
85
+
86
+ def test_malformed_entries_in_payload_are_dropped() -> None:
87
+ get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("a", "n")], now=0.0)
88
+ path = next(iter(cache_dir().glob("*.json")))
89
+ path.write_text(
90
+ json.dumps({"ts": 0.0, "items": [["ok", "name"], "bad", ["x"], [1, 2, 3]]}),
91
+ encoding="utf-8",
92
+ )
93
+ out = get_or_fetch("k", ttl=60.0, fetch_fn=lambda: [("z", "z")], now=1.0)
94
+ assert out == [("ok", "name")]
95
+
96
+
97
+ def test_cache_ttl_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
98
+ assert cache_ttl() == 60.0
99
+ monkeypatch.setenv("VSC_COMPLETE_TTL", "5")
100
+ assert cache_ttl() == 5.0
101
+ monkeypatch.setenv("VSC_COMPLETE_TTL", "not-a-number")
102
+ assert cache_ttl() == 60.0 # invalid -> default, never raises
103
+
104
+
105
+ def test_cache_dir_under_override() -> None:
106
+ assert cache_dir().parent == Path(__import__("os").environ["VSC_CACHE_DIR"])
107
+ assert cache_dir().name == "completion"
@@ -0,0 +1,205 @@
1
+ """Live id completion: opt-in, cached, timeout-bounded, blanket fail-soft.
2
+
3
+ No real vCenter is touched — the list-op fetch (`_fetch_ids`) is mocked. These
4
+ tests pin the safety contract: off by default, never raise, never hang, and
5
+ never cache a failure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import time
12
+ from pathlib import Path
13
+ from typing import ClassVar
14
+
15
+ import pytest
16
+
17
+ from vsc.gen import complete_dynamic as cd
18
+ from vsc.gen.builder import _build_signature
19
+ from vsc.gen.model import Operation, Param, ParamKind
20
+ from vsc.gen.resources import ResourceSource
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def _isolated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
25
+ monkeypatch.setenv("VSC_CACHE_DIR", str(tmp_path))
26
+ monkeypatch.delenv("VSC_COMPLETE_DYNAMIC", raising=False)
27
+ monkeypatch.delenv("VSC_COMPLETE_TTL", raising=False)
28
+ monkeypatch.delenv("VSC_COMPLETE_TIMEOUT", raising=False)
29
+ # active profile lookup should be inert in tests
30
+ monkeypatch.setattr(cd, "active_profile_name", lambda: None)
31
+
32
+
33
+ def _fake_items(monkeypatch: pytest.MonkeyPatch, items: list[tuple[str, str]]) -> None:
34
+ monkeypatch.setattr(cd, "_fetch_ids", lambda _src: list(items))
35
+
36
+
37
+ def test_disabled_by_default_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
38
+ # Even a working fetch must not run when the opt-in is unset.
39
+ def boom(_src: object) -> list[tuple[str, str]]:
40
+ raise AssertionError("fetch must not run when dynamic completion is disabled")
41
+
42
+ monkeypatch.setattr(cd, "_fetch_ids", boom)
43
+ assert cd.resource_completer("VirtualMachine")("") == []
44
+
45
+
46
+ def test_disabled_opens_no_connection(monkeypatch: pytest.MonkeyPatch) -> None:
47
+ def boom(_backend: str) -> object:
48
+ raise AssertionError("no connection at <TAB> by default")
49
+
50
+ monkeypatch.setattr(cd, "connect_for_backend", boom)
51
+ assert cd.resource_completer("VirtualMachine")("vm-") == []
52
+
53
+
54
+ def test_enabled_returns_ids_prefix_filtered_with_name_help(
55
+ monkeypatch: pytest.MonkeyPatch,
56
+ ) -> None:
57
+ monkeypatch.setenv("VSC_COMPLETE_DYNAMIC", "1")
58
+ _fake_items(monkeypatch, [("vm-1", "web"), ("vm-2", "db"), ("host-1", "esx")])
59
+ complete = cd.resource_completer("VirtualMachine")
60
+ assert complete("") == [("vm-1", "web"), ("vm-2", "db"), ("host-1", "esx")]
61
+ assert complete("vm-") == [("vm-1", "web"), ("vm-2", "db")]
62
+ assert complete("vm-1") == [("vm-1", "web")]
63
+
64
+
65
+ def test_cache_hit_avoids_second_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
66
+ monkeypatch.setenv("VSC_COMPLETE_DYNAMIC", "1")
67
+ calls = 0
68
+
69
+ def fetch(_src: object) -> list[tuple[str, str]]:
70
+ nonlocal calls
71
+ calls += 1
72
+ return [("vm-1", "web")]
73
+
74
+ monkeypatch.setattr(cd, "_fetch_ids", fetch)
75
+ complete = cd.resource_completer("VirtualMachine")
76
+ complete("")
77
+ complete("")
78
+ assert calls == 1 # second press served from cache
79
+
80
+
81
+ def test_fetch_error_returns_empty_and_is_not_cached(monkeypatch: pytest.MonkeyPatch) -> None:
82
+ monkeypatch.setenv("VSC_COMPLETE_DYNAMIC", "1")
83
+ calls = 0
84
+
85
+ def boom(_src: object) -> list[tuple[str, str]]:
86
+ nonlocal calls
87
+ calls += 1
88
+ raise RuntimeError("auth failed")
89
+
90
+ monkeypatch.setattr(cd, "_fetch_ids", boom)
91
+ complete = cd.resource_completer("VirtualMachine")
92
+ assert complete("") == []
93
+ assert complete("") == []
94
+ assert calls == 2 # failure was not cached; it retried
95
+
96
+
97
+ def test_timeout_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
98
+ monkeypatch.setenv("VSC_COMPLETE_DYNAMIC", "1")
99
+ monkeypatch.setenv("VSC_COMPLETE_TIMEOUT", "0.05")
100
+
101
+ def slow(_src: object) -> list[tuple[str, str]]:
102
+ time.sleep(1.0)
103
+ return [("vm-1", "web")]
104
+
105
+ monkeypatch.setattr(cd, "_fetch_ids", slow)
106
+ assert cd.resource_completer("VirtualMachine")("") == []
107
+
108
+
109
+ def test_unknown_resource_type_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
110
+ monkeypatch.setenv("VSC_COMPLETE_DYNAMIC", "1")
111
+ assert cd.resource_completer("NoSuchType")("") == []
112
+
113
+
114
+ def test_none_resource_type_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
115
+ monkeypatch.setenv("VSC_COMPLETE_DYNAMIC", "1")
116
+ assert cd.resource_completer(None)("") == []
117
+
118
+
119
+ def test_extract_handles_plain_and_cursor_results() -> None:
120
+ src = ResourceSource(
121
+ backend="vsphere",
122
+ service_cls=object,
123
+ list_op=Operation(
124
+ backend="vsphere",
125
+ service_cls=object,
126
+ iface_id="x.VM",
127
+ op_id="list",
128
+ method_name="list",
129
+ cli_verb="list",
130
+ http_method="GET",
131
+ url_template="/vm",
132
+ ),
133
+ id_field="vm",
134
+ name_field="name",
135
+ )
136
+
137
+ class Row:
138
+ def __init__(self, vm: str, name: str) -> None:
139
+ self.vm = vm
140
+ self.name = name
141
+
142
+ plain = [Row("vm-1", "web"), Row("vm-2", "db")]
143
+ assert cd._extract(plain, src) == [("vm-1", "web"), ("vm-2", "db")]
144
+
145
+ class Cursor:
146
+ results: ClassVar[list[object]] = [Row("vm-9", "z")]
147
+
148
+ assert cd._extract(Cursor(), src) == [("vm-9", "z")]
149
+
150
+
151
+ # --------------------------------------------------------------------------- #
152
+ # Builder wiring: ID-kind args/options carry the resource completer
153
+ # --------------------------------------------------------------------------- #
154
+
155
+
156
+ def _id_param(*, in_path: bool) -> Param:
157
+ p = Param(name="vm", kind=ParamKind.ID, required=True, in_path=in_path)
158
+ p.resource_types = "VirtualMachine"
159
+ return p
160
+
161
+
162
+ def _op_with(param: Param) -> Operation:
163
+ return Operation(
164
+ backend="vsphere",
165
+ service_cls=object,
166
+ iface_id="com.vmware.vcenter.VM",
167
+ op_id="get",
168
+ method_name="get",
169
+ cli_verb="get",
170
+ http_method="GET",
171
+ url_template="/vcenter/vm/{vm}",
172
+ path_vars=["vm"] if param.in_path else [],
173
+ params=[param],
174
+ )
175
+
176
+
177
+ def test_id_path_argument_gets_resource_completer() -> None:
178
+ sig, _spec, _fp = _build_signature(_op_with(_id_param(in_path=True)))
179
+ arg = sig.parameters["vm"].default
180
+ assert arg.autocompletion is not None
181
+ # Inert (offline) by default — no env, so no fetch, returns [].
182
+ assert arg.autocompletion("") == []
183
+
184
+
185
+ def test_id_option_gets_resource_completer() -> None:
186
+ sig, _spec, _fp = _build_signature(_op_with(_id_param(in_path=False)))
187
+ opt = sig.parameters["vm"].default
188
+ assert opt.autocompletion is not None
189
+ assert opt.autocompletion("") == []
190
+
191
+
192
+ def test_id_param_without_resource_type_has_no_completer() -> None:
193
+ bare = Param(name="vm", kind=ParamKind.ID, required=False, in_path=False)
194
+ sig, _spec, _fp = _build_signature(_op_with(bare))
195
+ assert sig.parameters["vm"].default.autocompletion is None
196
+
197
+
198
+ def test_building_signature_opens_no_connection(monkeypatch: pytest.MonkeyPatch) -> None:
199
+ def boom(_backend: str) -> object:
200
+ raise AssertionError("signature build must stay offline")
201
+
202
+ monkeypatch.setattr(cd, "connect_for_backend", boom)
203
+ sig = inspect.signature # keep import used
204
+ assert sig is not None
205
+ _build_signature(_op_with(_id_param(in_path=True))) # must not raise
@@ -0,0 +1,115 @@
1
+ """The resource-type -> list-op registry that powers live id completion.
2
+
3
+ The registry is built purely by introspecting the SDK metadata (``discover_all``);
4
+ no network call is involved. It maps a vAPI ``resource_type`` (carried on an
5
+ ``ID``-kind :class:`Param`) to the *list* operation that enumerates those ids,
6
+ plus which element fields hold the id and a human-readable name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from vsc.gen.model import Operation, Param, ParamKind
12
+ from vsc.gen.resources import ResourceSource, build_resource_registry, resource_source
13
+
14
+
15
+ def test_virtual_machine_resolves_to_vm_list() -> None:
16
+ src = resource_source("VirtualMachine")
17
+ assert src is not None
18
+ assert src.backend == "vsphere"
19
+ assert src.list_op.cli_verb == "list"
20
+ assert src.list_op.service_short == "vm"
21
+ assert src.id_field == "vm"
22
+ assert src.name_field == "name"
23
+
24
+
25
+ def test_host_and_cluster_resolve() -> None:
26
+ host = resource_source("HostSystem")
27
+ assert host is not None
28
+ assert host.backend == "vsphere"
29
+ assert host.id_field == "host"
30
+ assert host.name_field == "name"
31
+
32
+ cluster = resource_source("ClusterComputeResource")
33
+ assert cluster is not None
34
+ assert cluster.id_field == "cluster"
35
+
36
+
37
+ def test_unknown_resource_type_returns_none() -> None:
38
+ assert resource_source("NoSuchType") is None
39
+
40
+
41
+ def test_subresource_types_are_unsupported() -> None:
42
+ # Disk/Ethernet ids only make sense relative to a parent VM (their list op
43
+ # needs a required ``vm`` path arg), so they are deliberately not registered:
44
+ # tab-completing them standalone is meaningless.
45
+ assert resource_source("com.vmware.vcenter.vm.hardware.Disk") is None
46
+
47
+
48
+ def test_registry_is_offline_and_covers_core_inventory() -> None:
49
+ registry = build_resource_registry()
50
+ # Every core vSphere inventory type the SDK annotates with a resource_type.
51
+ for rt in (
52
+ "VirtualMachine",
53
+ "HostSystem",
54
+ "ClusterComputeResource",
55
+ "Datacenter",
56
+ "Datastore",
57
+ "ResourcePool",
58
+ ):
59
+ assert rt in registry, rt
60
+ assert isinstance(registry[rt], ResourceSource)
61
+
62
+
63
+ def test_injected_operations_resolve_without_discovery() -> None:
64
+ # A by-id "get" op carries the resource_type; the sibling "list" op (same
65
+ # service, no required path arg) yields the ids. The builder must correlate
66
+ # them from an injected op list, never touching discover_all.
67
+ id_param = Param(name="thing", kind=ParamKind.ID, required=True, in_path=True)
68
+ id_param.resource_types = "Widget"
69
+ get_op = Operation(
70
+ backend="vsphere",
71
+ service_cls=object,
72
+ iface_id="com.example.Thing",
73
+ op_id="get",
74
+ method_name="get",
75
+ cli_verb="get",
76
+ http_method="GET",
77
+ url_template="/things/{thing}",
78
+ path_vars=["thing"],
79
+ params=[id_param],
80
+ )
81
+ list_op = Operation(
82
+ backend="vsphere",
83
+ service_cls=object,
84
+ iface_id="com.example.Thing",
85
+ op_id="list",
86
+ method_name="list",
87
+ cli_verb="list",
88
+ http_method="GET",
89
+ url_template="/things",
90
+ params=[],
91
+ )
92
+ registry = build_resource_registry([get_op, list_op])
93
+ src = registry.get("Widget")
94
+ assert src is not None
95
+ assert src.list_op is list_op
96
+
97
+
98
+ def test_list_op_with_required_path_arg_is_skipped() -> None:
99
+ # A "list" that needs a parent id is a sub-resource list, not a registry source.
100
+ id_param = Param(name="child", kind=ParamKind.ID, required=True, in_path=True)
101
+ id_param.resource_types = "Child"
102
+ parent = Param(name="parent", kind=ParamKind.ID, required=True, in_path=True)
103
+ list_op = Operation(
104
+ backend="vsphere",
105
+ service_cls=object,
106
+ iface_id="com.example.Child",
107
+ op_id="list",
108
+ method_name="list",
109
+ cli_verb="list",
110
+ http_method="GET",
111
+ url_template="/parents/{parent}/children",
112
+ path_vars=["parent"],
113
+ params=[id_param, parent],
114
+ )
115
+ assert build_resource_registry([list_op]).get("Child") is None
@@ -1326,7 +1326,7 @@ wheels = [
1326
1326
 
1327
1327
  [[package]]
1328
1328
  name = "vcf-super-cli"
1329
- version = "0.3.0"
1329
+ version = "0.4.0"
1330
1330
  source = { editable = "." }
1331
1331
  dependencies = [
1332
1332
  { name = "keyring" },
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.4.0"