kx-cli 0.0.6__tar.gz → 0.0.7__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.
- {kx_cli-0.0.6/src/kx_cli.egg-info → kx_cli-0.0.7}/PKG-INFO +43 -7
- {kx_cli-0.0.6 → kx_cli-0.0.7}/README.md +39 -5
- {kx_cli-0.0.6 → kx_cli-0.0.7}/pyproject.toml +5 -2
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/delete.py +9 -2
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/describe.py +4 -1
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/edit.py +6 -1
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/events.py +12 -1
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/exec.py +3 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/get.py +13 -4
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/logs.py +17 -3
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/port_forward.py +4 -1
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/console.py +64 -10
- kx_cli-0.0.7/src/kx/errors.py +80 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/kinds.py +4 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/main.py +115 -25
- kx_cli-0.0.7/src/kx/refresh.py +63 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/state.py +23 -2
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/types.py +2 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7/src/kx_cli.egg-info}/PKG-INFO +43 -7
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx_cli.egg-info/SOURCES.txt +3 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx_cli.egg-info/requires.txt +4 -1
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_cli_get.py +14 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_cli_help.py +12 -0
- kx_cli-0.0.7/tests/test_cli_kind_alias.py +155 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_console.py +23 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_delete.py +27 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_describe.py +26 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_edit.py +26 -0
- kx_cli-0.0.7/tests/test_error_handling.py +193 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_events.py +45 -2
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_exec.py +36 -2
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_get.py +25 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_logs.py +49 -0
- kx_cli-0.0.7/tests/test_namespace.py +105 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_port_forward.py +26 -0
- kx_cli-0.0.7/tests/test_refresh.py +82 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_state.py +50 -1
- kx_cli-0.0.6/src/kx/errors.py +0 -27
- kx_cli-0.0.6/tests/test_error_handling.py +0 -94
- kx_cli-0.0.6/tests/test_namespace.py +0 -51
- {kx_cli-0.0.6 → kx_cli-0.0.7}/LICENSE +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/setup.cfg +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/__init__.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/__init__.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/back.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/diagnostic.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/drop.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/forward.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/labels.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/namespace.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/rollout.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/scale.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/state.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/theme.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/tree.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/commands/yaml.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/config.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/diagnostics.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/events.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/graph.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/index.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/k8s.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/kubectl.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx/themes.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx_cli.egg-info/dependency_links.txt +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx_cli.egg-info/entry_points.txt +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/src/kx_cli.egg-info/top_level.txt +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_back.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_config.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_diagnostic.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_diagnostics.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_drop.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_forward.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_graph.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_index.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_kinds.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_labels.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_rollout.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_scale.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_theme.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_themes.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_tree.py +0 -0
- {kx_cli-0.0.6 → kx_cli-0.0.7}/tests/test_yaml.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kx-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.7
|
|
4
4
|
Summary: kubectl wrapper with index-based resource selection
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -9,7 +9,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
9
9
|
Requires-Python: >=3.11
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Requires-Dist: typer==0.
|
|
12
|
+
Requires-Dist: typer==0.27.0
|
|
13
13
|
Requires-Dist: kubernetes
|
|
14
14
|
Requires-Dist: rich
|
|
15
15
|
Provides-Extra: dev
|
|
@@ -17,6 +17,8 @@ Requires-Dist: ruff; extra == "dev"
|
|
|
17
17
|
Requires-Dist: pre-commit; extra == "dev"
|
|
18
18
|
Requires-Dist: pytest; extra == "dev"
|
|
19
19
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
20
|
+
Provides-Extra: build
|
|
21
|
+
Requires-Dist: pyinstaller; extra == "build"
|
|
20
22
|
Dynamic: license-file
|
|
21
23
|
|
|
22
24
|
<div align="center">
|
|
@@ -48,10 +50,42 @@ Dynamic: license-file
|
|
|
48
50
|
|
|
49
51
|
## Install
|
|
50
52
|
|
|
53
|
+
Requires Python 3.11+ and `kubectl` on your PATH.
|
|
54
|
+
|
|
55
|
+
With [uv](https://docs.astral.sh/uv/) (recommended):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv tool install kx-cli
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
With [pipx](https://pipx.pypa.io/):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pipx install kx-cli
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With pip:
|
|
68
|
+
|
|
51
69
|
```bash
|
|
52
70
|
pip install kx-cli
|
|
53
71
|
```
|
|
54
72
|
|
|
73
|
+
As a kubectl plugin via [krew](https://krew.sigs.k8s.io/) (no Python required; pending krew-index acceptance):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
kubectl krew install kx
|
|
77
|
+
alias kx="kubectl kx"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Standalone binaries (linux/macOS, amd64/arm64, no Python required) are attached to each [GitHub Release](https://github.com/jzills/kx/releases).
|
|
81
|
+
|
|
82
|
+
Or try it without installing (the package is `kx-cli`, the command is `kx`):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uvx --from kx-cli kx get pods
|
|
86
|
+
pipx run --spec kx-cli kx get pods
|
|
87
|
+
```
|
|
88
|
+
|
|
55
89
|
## Usage
|
|
56
90
|
|
|
57
91
|
### List resources
|
|
@@ -60,7 +94,9 @@ pip install kx-cli
|
|
|
60
94
|
kx get <resource> [--match|-m <substring>] [kubectl flags...]
|
|
61
95
|
```
|
|
62
96
|
|
|
63
|
-
Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive).
|
|
97
|
+
Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive). All-namespace listings (`-A`) are display-only — rows aren't indexed, since names aren't unique across namespaces; scope to a namespace to select.
|
|
98
|
+
|
|
99
|
+
Known kinds can drop the `get`: `kx pods`, `kx deploy -n kube-system`, `kx svc --match api`. This covers the built-in kinds and their kubectl shorthands (`po`, `deploy`, `svc`, `sts`, ...); existing commands take precedence, so `kx ns 3` still switches namespaces (bare `kx ns` lists them). CRDs and other resource types still use `kx get <resource>`. An integer after a kind is an index into the current state: `kx po 3` (or `kx get po 3`) relists just that pod, erroring if index 3 isn't a pod. Multiple indexes work too: `kx po 1 3`.
|
|
64
100
|
|
|
65
101
|
```
|
|
66
102
|
$ kx get pods
|
|
@@ -73,19 +109,19 @@ Pods · default · 3 items
|
|
|
73
109
|
|
|
74
110
|
All subsequent commands reference resources by their `X` index from the last `kx get`.
|
|
75
111
|
|
|
76
|
-
Global flags: `--no-color` disables styled output,
|
|
112
|
+
Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
|
|
77
113
|
|
|
78
114
|
### Commands
|
|
79
115
|
|
|
80
116
|
<!-- commands-table-start -->
|
|
81
117
|
| Command | Description |
|
|
82
118
|
|---|---|
|
|
83
|
-
| `kx get <resource> [--match/-m
|
|
119
|
+
| `kx get <resource> [--match/-m str] [kubectl flags...]` | List resources and assign index numbers for use with other commands; shorthand: kx <kind> (e.g. kx pods, kx po 3). |
|
|
84
120
|
| `kx describe <indexes>... [kubectl flags...]` | Show full kubectl describe output for one or more indexed resources. |
|
|
85
121
|
| `kx events <indexes>...` | Show Kubernetes events for one or more indexed resources. |
|
|
86
122
|
| `kx logs <index> [kubectl flags...]` | Stream logs for an indexed resource; aggregates across pods for Deployments, StatefulSets, DaemonSets, and Services. |
|
|
87
123
|
| `kx labels <indexes>... [--selector/-s]` | Show labels for one or more indexed resources; --selector formats output as a label selector. |
|
|
88
|
-
| `kx yaml <indexes>... [--show
|
|
124
|
+
| `kx yaml <indexes>... [--show str]` | Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields. |
|
|
89
125
|
| `kx delete <indexes>... [--yes/-y]` | Delete one or more indexed resources (prompts for confirmation unless --yes). |
|
|
90
126
|
| `kx edit <index> [kubectl flags...]` | Open an indexed resource in your editor via kubectl edit. |
|
|
91
127
|
| `kx exec <index> [<cmd>...] [kubectl flags...]` | Open an interactive shell in an indexed pod (bash, falling back to sh). |
|
|
@@ -94,7 +130,7 @@ Global flags: `--no-color` disables styled output, `--version` prints the instal
|
|
|
94
130
|
| `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
|
|
95
131
|
| `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
|
|
96
132
|
| `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
|
|
97
|
-
| `kx namespace <index
|
|
133
|
+
| `kx namespace [<index>]` | List namespaces, or switch to an indexed one; alias: kx ns. |
|
|
98
134
|
| `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
|
|
99
135
|
| `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
|
|
100
136
|
| `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
|
|
@@ -27,10 +27,42 @@
|
|
|
27
27
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
30
|
+
Requires Python 3.11+ and `kubectl` on your PATH.
|
|
31
|
+
|
|
32
|
+
With [uv](https://docs.astral.sh/uv/) (recommended):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install kx-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
With [pipx](https://pipx.pypa.io/):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pipx install kx-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
With pip:
|
|
45
|
+
|
|
30
46
|
```bash
|
|
31
47
|
pip install kx-cli
|
|
32
48
|
```
|
|
33
49
|
|
|
50
|
+
As a kubectl plugin via [krew](https://krew.sigs.k8s.io/) (no Python required; pending krew-index acceptance):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
kubectl krew install kx
|
|
54
|
+
alias kx="kubectl kx"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Standalone binaries (linux/macOS, amd64/arm64, no Python required) are attached to each [GitHub Release](https://github.com/jzills/kx/releases).
|
|
58
|
+
|
|
59
|
+
Or try it without installing (the package is `kx-cli`, the command is `kx`):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uvx --from kx-cli kx get pods
|
|
63
|
+
pipx run --spec kx-cli kx get pods
|
|
64
|
+
```
|
|
65
|
+
|
|
34
66
|
## Usage
|
|
35
67
|
|
|
36
68
|
### List resources
|
|
@@ -39,7 +71,9 @@ pip install kx-cli
|
|
|
39
71
|
kx get <resource> [--match|-m <substring>] [kubectl flags...]
|
|
40
72
|
```
|
|
41
73
|
|
|
42
|
-
Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive).
|
|
74
|
+
Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive). All-namespace listings (`-A`) are display-only — rows aren't indexed, since names aren't unique across namespaces; scope to a namespace to select.
|
|
75
|
+
|
|
76
|
+
Known kinds can drop the `get`: `kx pods`, `kx deploy -n kube-system`, `kx svc --match api`. This covers the built-in kinds and their kubectl shorthands (`po`, `deploy`, `svc`, `sts`, ...); existing commands take precedence, so `kx ns 3` still switches namespaces (bare `kx ns` lists them). CRDs and other resource types still use `kx get <resource>`. An integer after a kind is an index into the current state: `kx po 3` (or `kx get po 3`) relists just that pod, erroring if index 3 isn't a pod. Multiple indexes work too: `kx po 1 3`.
|
|
43
77
|
|
|
44
78
|
```
|
|
45
79
|
$ kx get pods
|
|
@@ -52,19 +86,19 @@ Pods · default · 3 items
|
|
|
52
86
|
|
|
53
87
|
All subsequent commands reference resources by their `X` index from the last `kx get`.
|
|
54
88
|
|
|
55
|
-
Global flags: `--no-color` disables styled output,
|
|
89
|
+
Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
|
|
56
90
|
|
|
57
91
|
### Commands
|
|
58
92
|
|
|
59
93
|
<!-- commands-table-start -->
|
|
60
94
|
| Command | Description |
|
|
61
95
|
|---|---|
|
|
62
|
-
| `kx get <resource> [--match/-m
|
|
96
|
+
| `kx get <resource> [--match/-m str] [kubectl flags...]` | List resources and assign index numbers for use with other commands; shorthand: kx <kind> (e.g. kx pods, kx po 3). |
|
|
63
97
|
| `kx describe <indexes>... [kubectl flags...]` | Show full kubectl describe output for one or more indexed resources. |
|
|
64
98
|
| `kx events <indexes>...` | Show Kubernetes events for one or more indexed resources. |
|
|
65
99
|
| `kx logs <index> [kubectl flags...]` | Stream logs for an indexed resource; aggregates across pods for Deployments, StatefulSets, DaemonSets, and Services. |
|
|
66
100
|
| `kx labels <indexes>... [--selector/-s]` | Show labels for one or more indexed resources; --selector formats output as a label selector. |
|
|
67
|
-
| `kx yaml <indexes>... [--show
|
|
101
|
+
| `kx yaml <indexes>... [--show str]` | Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields. |
|
|
68
102
|
| `kx delete <indexes>... [--yes/-y]` | Delete one or more indexed resources (prompts for confirmation unless --yes). |
|
|
69
103
|
| `kx edit <index> [kubectl flags...]` | Open an indexed resource in your editor via kubectl edit. |
|
|
70
104
|
| `kx exec <index> [<cmd>...] [kubectl flags...]` | Open an interactive shell in an indexed pod (bash, falling back to sh). |
|
|
@@ -73,7 +107,7 @@ Global flags: `--no-color` disables styled output, `--version` prints the instal
|
|
|
73
107
|
| `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
|
|
74
108
|
| `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
|
|
75
109
|
| `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
|
|
76
|
-
| `kx namespace <index
|
|
110
|
+
| `kx namespace [<index>]` | List namespaces, or switch to an indexed one; alias: kx ns. |
|
|
77
111
|
| `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
|
|
78
112
|
| `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
|
|
79
113
|
| `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kx-cli"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.7"
|
|
4
4
|
description = "kubectl wrapper with index-based resource selection"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -11,7 +11,7 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.13",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"typer==0.
|
|
14
|
+
"typer==0.27.0",
|
|
15
15
|
"kubernetes",
|
|
16
16
|
"rich",
|
|
17
17
|
]
|
|
@@ -23,6 +23,9 @@ dev = [
|
|
|
23
23
|
"pytest",
|
|
24
24
|
"pytest-cov",
|
|
25
25
|
]
|
|
26
|
+
build = [
|
|
27
|
+
"pyinstaller",
|
|
28
|
+
]
|
|
26
29
|
|
|
27
30
|
[project.scripts]
|
|
28
31
|
kx = "kx.main:app"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
from contextlib import nullcontext
|
|
2
|
+
|
|
1
3
|
from kx.kubectl import KubectlServiceProtocol
|
|
2
4
|
from kx.state import StateServiceProtocol
|
|
3
|
-
from kx.types import Confirm
|
|
5
|
+
from kx.types import Confirm, Status
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
class DeleteCommand:
|
|
@@ -9,14 +11,19 @@ class DeleteCommand:
|
|
|
9
11
|
state: StateServiceProtocol,
|
|
10
12
|
kubectl: KubectlServiceProtocol,
|
|
11
13
|
confirm: Confirm,
|
|
14
|
+
status: Status | None = None,
|
|
12
15
|
):
|
|
13
16
|
self.state = state
|
|
14
17
|
self.kubectl = kubectl
|
|
15
18
|
self.confirm = confirm
|
|
19
|
+
self.status = status or (lambda _msg: nullcontext())
|
|
16
20
|
|
|
17
21
|
def execute(self, index: int, yes: bool) -> str:
|
|
18
22
|
name, namespace, kind = self.state.fields(index)
|
|
23
|
+
# The confirm prompt must stay outside the spinner: a prompt inside a
|
|
24
|
+
# Live region breaks input.
|
|
19
25
|
if not yes:
|
|
20
26
|
self.confirm(f"Delete {kind}/{name} in {namespace}?")
|
|
21
|
-
self.
|
|
27
|
+
with self.status("deleting"):
|
|
28
|
+
self.kubectl.run(["delete", kind, name, "-n", namespace])
|
|
22
29
|
return f"Deleted {kind}/{name}"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from kx.kubectl import KubectlServiceProtocol
|
|
2
|
+
from kx.refresh import ensure_exists
|
|
2
3
|
from kx.state import StateServiceProtocol
|
|
3
4
|
|
|
4
5
|
|
|
@@ -14,6 +15,8 @@ class DescribeCommand:
|
|
|
14
15
|
def execute(self, index: int, extra_args: list[str] | None = None) -> None:
|
|
15
16
|
extra_args = extra_args or []
|
|
16
17
|
name, namespace, kind = self.state.fields(index)
|
|
17
|
-
self.kubectl.run_interactive(
|
|
18
|
+
rc = self.kubectl.run_interactive(
|
|
18
19
|
["describe", kind, name, "-n", namespace, *extra_args]
|
|
19
20
|
)
|
|
21
|
+
if rc != 0:
|
|
22
|
+
ensure_exists(self.kubectl, kind, name, namespace)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from kx.kubectl import KubectlServiceProtocol
|
|
2
|
+
from kx.refresh import ensure_exists
|
|
2
3
|
from kx.state import StateServiceProtocol
|
|
3
4
|
|
|
4
5
|
|
|
@@ -10,4 +11,8 @@ class EditCommand:
|
|
|
10
11
|
def execute(self, index: int, extra_args: list[str] | None = None) -> None:
|
|
11
12
|
extra_args = extra_args or []
|
|
12
13
|
name, namespace, kind = self.state.fields(index)
|
|
13
|
-
self.kubectl.run_interactive(
|
|
14
|
+
rc = self.kubectl.run_interactive(
|
|
15
|
+
["edit", kind, name, "-n", namespace, *extra_args]
|
|
16
|
+
)
|
|
17
|
+
if rc != 0:
|
|
18
|
+
ensure_exists(self.kubectl, kind, name, namespace)
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
from kx.events import EventsServiceProtocol
|
|
2
|
+
from kx.kubectl import KubectlServiceProtocol
|
|
3
|
+
from kx.refresh import ensure_exists
|
|
2
4
|
from kx.state import StateServiceProtocol
|
|
3
5
|
|
|
4
6
|
|
|
5
7
|
class EventsCommand:
|
|
6
|
-
def __init__(
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
state: StateServiceProtocol,
|
|
11
|
+
events: EventsServiceProtocol,
|
|
12
|
+
kubectl: KubectlServiceProtocol,
|
|
13
|
+
):
|
|
7
14
|
self.state = state
|
|
8
15
|
self.events = events
|
|
16
|
+
self.kubectl = kubectl
|
|
9
17
|
|
|
10
18
|
def execute(self, index: int) -> str:
|
|
11
19
|
name, namespace, kind = self.state.fields(index)
|
|
@@ -13,6 +21,9 @@ class EventsCommand:
|
|
|
13
21
|
filtered = self.events.filter(all_events, name, kind)
|
|
14
22
|
|
|
15
23
|
if not filtered:
|
|
24
|
+
# Deleted resources keep their events ~1h; only an empty result
|
|
25
|
+
# warrants checking whether the target itself is gone.
|
|
26
|
+
ensure_exists(self.kubectl, kind, name, namespace)
|
|
16
27
|
return "No events found"
|
|
17
28
|
|
|
18
29
|
output = []
|
|
@@ -2,6 +2,7 @@ import subprocess
|
|
|
2
2
|
|
|
3
3
|
from kx.kinds import Kind
|
|
4
4
|
from kx.kubectl import KubectlServiceProtocol
|
|
5
|
+
from kx.refresh import ensure_exists
|
|
5
6
|
from kx.state import StateServiceProtocol
|
|
6
7
|
|
|
7
8
|
|
|
@@ -29,6 +30,7 @@ class ExecCommand:
|
|
|
29
30
|
stderr=subprocess.DEVNULL,
|
|
30
31
|
)
|
|
31
32
|
if rc != 0:
|
|
33
|
+
ensure_exists(self.kubectl, str(Kind.Pod), name, namespace)
|
|
32
34
|
raise ValueError(f"Command failed in container (exit {rc}).")
|
|
33
35
|
else:
|
|
34
36
|
for shell in self.shells:
|
|
@@ -50,6 +52,7 @@ class ExecCommand:
|
|
|
50
52
|
["exec", "-it", name, "-n", namespace, *extra_args, "--", shell]
|
|
51
53
|
)
|
|
52
54
|
return
|
|
55
|
+
ensure_exists(self.kubectl, str(Kind.Pod), name, namespace)
|
|
53
56
|
raise ValueError(
|
|
54
57
|
"No shell found in container. Provide an explicit command: kx exec <index> -- /path/to/binary"
|
|
55
58
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from kx.index import IndexServiceProtocol
|
|
2
2
|
from kx.kinds import normalize_kind
|
|
3
3
|
from kx.kubectl import KubectlServiceProtocol
|
|
4
|
-
from kx.state import State, StateServiceProtocol
|
|
4
|
+
from kx.state import Query, State, StateServiceProtocol
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def _extract_namespace(extra_args: list[str]) -> str | None:
|
|
@@ -34,14 +34,23 @@ class GetCommand:
|
|
|
34
34
|
output = self.kubectl.run(["get", resource, *extra_args])
|
|
35
35
|
if filter_term:
|
|
36
36
|
output = self.index.filter(output, filter_term)
|
|
37
|
-
indexed_output, names = self.index.add(output)
|
|
38
37
|
all_namespaces = any(arg in ("-A", "--all-namespaces") for arg in extra_args)
|
|
39
|
-
if
|
|
38
|
+
if all_namespaces:
|
|
39
|
+
# Names aren't unique across namespaces, so `-A` results are never
|
|
40
|
+
# indexed — returning unindexed output keeps dead X numbers off
|
|
41
|
+
# the screen.
|
|
42
|
+
return output
|
|
43
|
+
indexed_output, names = self.index.add(output)
|
|
44
|
+
if names:
|
|
40
45
|
namespace = (
|
|
41
46
|
_extract_namespace(extra_args) or self.kubectl.current_namespace()
|
|
42
47
|
)
|
|
43
48
|
kind = normalize_kind(resource)
|
|
44
49
|
self.state.save(
|
|
45
|
-
State(
|
|
50
|
+
State(
|
|
51
|
+
resources={name: kind for name in names},
|
|
52
|
+
namespace=namespace,
|
|
53
|
+
query=Query(resource=resource, args=extra_args, match=filter_term),
|
|
54
|
+
)
|
|
46
55
|
)
|
|
47
56
|
return indexed_output
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from contextlib import nullcontext
|
|
2
3
|
|
|
3
4
|
from kx.kinds import Kind
|
|
4
5
|
from kx.kubectl import KubectlServiceProtocol
|
|
6
|
+
from kx.refresh import ensure_exists
|
|
5
7
|
from kx.state import StateServiceProtocol
|
|
8
|
+
from kx.types import Status
|
|
6
9
|
|
|
7
10
|
_AGGREGATE_KINDS = {Kind.Deployment, Kind.StatefulSet, Kind.DaemonSet, Kind.Service}
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
class LogsCommand:
|
|
11
|
-
def __init__(
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
state: StateServiceProtocol,
|
|
17
|
+
kubectl: KubectlServiceProtocol,
|
|
18
|
+
status: Status | None = None,
|
|
19
|
+
):
|
|
12
20
|
self.state = state
|
|
13
21
|
self.kubectl = kubectl
|
|
22
|
+
self.status = status or (lambda _msg: nullcontext())
|
|
14
23
|
|
|
15
24
|
def execute(self, index: int, extra_args: list[str] | None = None) -> None:
|
|
16
25
|
extra_args = extra_args or []
|
|
17
26
|
name, namespace, kind = self.state.fields(index)
|
|
18
27
|
if kind == Kind.Pod:
|
|
19
|
-
self.kubectl.run_interactive(
|
|
28
|
+
rc = self.kubectl.run_interactive(
|
|
29
|
+
["logs", name, "-n", namespace, *extra_args]
|
|
30
|
+
)
|
|
31
|
+
if rc != 0:
|
|
32
|
+
ensure_exists(self.kubectl, kind, name, namespace)
|
|
20
33
|
elif kind in _AGGREGATE_KINDS:
|
|
21
34
|
selector = self._selector(name, namespace, kind)
|
|
22
35
|
self.kubectl.run_interactive(
|
|
@@ -26,7 +39,8 @@ class LogsCommand:
|
|
|
26
39
|
raise ValueError(f"Logs are not supported for '{kind}'.")
|
|
27
40
|
|
|
28
41
|
def _selector(self, name: str, namespace: str, kind: str) -> str:
|
|
29
|
-
|
|
42
|
+
with self.status("resolving pod selector"):
|
|
43
|
+
raw = self.kubectl.run(["get", kind, name, "-n", namespace, "-o", "json"])
|
|
30
44
|
obj = json.loads(raw)
|
|
31
45
|
labels = self._extract_labels(obj, kind)
|
|
32
46
|
if not labels:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from kx.kinds import Kind
|
|
2
2
|
from kx.kubectl import KubectlServiceProtocol
|
|
3
|
+
from kx.refresh import ensure_exists
|
|
3
4
|
from kx.state import StateServiceProtocol
|
|
4
5
|
|
|
5
6
|
_SUPPORTED_KINDS = {
|
|
@@ -24,6 +25,8 @@ class PortForwardCommand:
|
|
|
24
25
|
name, namespace, kind = self.state.fields(index)
|
|
25
26
|
if kind not in _SUPPORTED_KINDS:
|
|
26
27
|
raise ValueError(f"port-forward is not supported for '{kind}'.")
|
|
27
|
-
self.kubectl.run_interactive(
|
|
28
|
+
rc = self.kubectl.run_interactive(
|
|
28
29
|
["port-forward", f"{kind}/{name}", port, "-n", namespace, *extra_args]
|
|
29
30
|
)
|
|
31
|
+
if rc != 0:
|
|
32
|
+
ensure_exists(self.kubectl, kind, name, namespace)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import json
|
|
3
|
-
|
|
3
|
+
import threading
|
|
4
|
+
from contextlib import contextmanager
|
|
4
5
|
from datetime import datetime, timezone
|
|
5
6
|
|
|
6
7
|
import typer
|
|
@@ -88,7 +89,7 @@ def print_success(msg: str) -> None:
|
|
|
88
89
|
|
|
89
90
|
def print_error(msg: str) -> None:
|
|
90
91
|
styled = re.sub(r"'([^']+)'", "[accent]'\\1'[/accent]", msg)
|
|
91
|
-
_console.print(f"[
|
|
92
|
+
_console.print(f"[error]✗[/error] [body]{styled}[/body]")
|
|
92
93
|
|
|
93
94
|
|
|
94
95
|
def print_banner(kind: str, name: str, namespace: str = "", extra: str = "") -> None:
|
|
@@ -100,12 +101,56 @@ def print_banner(kind: str, name: str, namespace: str = "", extra: str = "") ->
|
|
|
100
101
|
_console.print(f"[muted]{' · '.join(parts)}[/muted]")
|
|
101
102
|
|
|
102
103
|
|
|
104
|
+
# How long a command runs silently before the spinner appears. Quick commands
|
|
105
|
+
# intentionally show nothing — a spinner that flashes for a fraction of a
|
|
106
|
+
# second is noise, not feedback.
|
|
107
|
+
_SPINNER_DELAY = 0.2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
103
111
|
def status(message: str):
|
|
104
|
-
"""Spinner shown while waiting on the cluster
|
|
105
|
-
|
|
112
|
+
"""Spinner shown while waiting on the cluster, only once the command has
|
|
113
|
+
run for _SPINNER_DELAY. A no-op off-terminal so piped output and test
|
|
114
|
+
captures never receive spinner frames."""
|
|
106
115
|
if not _console.is_terminal:
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
yield
|
|
117
|
+
return
|
|
118
|
+
# Low refresh with a matching spinner speed: roughly one frame per redraw,
|
|
119
|
+
# so the line isn't rewritten faster than the animation advances (the
|
|
120
|
+
# default 12.5 fps redraw flickers on some terminals).
|
|
121
|
+
live_status = _console.status(
|
|
122
|
+
f"[muted]{message}…[/muted]",
|
|
123
|
+
spinner_style="muted",
|
|
124
|
+
refresh_per_second=4,
|
|
125
|
+
speed=0.4,
|
|
126
|
+
)
|
|
127
|
+
lock = threading.Lock()
|
|
128
|
+
started = False
|
|
129
|
+
finished = False
|
|
130
|
+
|
|
131
|
+
def show() -> None:
|
|
132
|
+
nonlocal started
|
|
133
|
+
with lock:
|
|
134
|
+
if finished:
|
|
135
|
+
return
|
|
136
|
+
started = True
|
|
137
|
+
live_status.start()
|
|
138
|
+
# Status.start() schedules the first paint a full refresh interval
|
|
139
|
+
# out (250ms at 4 fps); paint now — the delay has already passed.
|
|
140
|
+
live_status._live.refresh()
|
|
141
|
+
|
|
142
|
+
timer = threading.Timer(_SPINNER_DELAY, show)
|
|
143
|
+
timer.start()
|
|
144
|
+
try:
|
|
145
|
+
yield
|
|
146
|
+
finally:
|
|
147
|
+
timer.cancel()
|
|
148
|
+
# The lock closes the race where the timer has fired but show() hasn't
|
|
149
|
+
# started the spinner yet, which would otherwise leave it running.
|
|
150
|
+
with lock:
|
|
151
|
+
finished = True
|
|
152
|
+
if started:
|
|
153
|
+
live_status.stop()
|
|
109
154
|
|
|
110
155
|
|
|
111
156
|
def confirm(message: str) -> None:
|
|
@@ -147,7 +192,9 @@ def _print_get_caption(resource_type: str, namespace: str, count: int) -> None:
|
|
|
147
192
|
)
|
|
148
193
|
|
|
149
194
|
|
|
150
|
-
def render_indexed_table(
|
|
195
|
+
def render_indexed_table(
|
|
196
|
+
text: str, resource_type: str, namespace: str, note: str | None = None
|
|
197
|
+
) -> None:
|
|
151
198
|
lines = [line for line in text.splitlines() if line.strip()]
|
|
152
199
|
if not lines:
|
|
153
200
|
# kubectl exits 0 with empty stdout when nothing matches ("No
|
|
@@ -157,7 +204,7 @@ def render_indexed_table(text: str, resource_type: str, namespace: str) -> None:
|
|
|
157
204
|
|
|
158
205
|
header_line = lines[0]
|
|
159
206
|
first_col = header_line.split()[0] if header_line.split() else ""
|
|
160
|
-
if first_col != "X":
|
|
207
|
+
if first_col != "X" and note is None:
|
|
161
208
|
_console.print(text, markup=False, highlight=False)
|
|
162
209
|
return
|
|
163
210
|
|
|
@@ -222,6 +269,8 @@ def render_indexed_table(text: str, resource_type: str, namespace: str) -> None:
|
|
|
222
269
|
|
|
223
270
|
_print_get_caption(resource_type, namespace, len(rows))
|
|
224
271
|
_console.print(table)
|
|
272
|
+
if note:
|
|
273
|
+
_console.print(f"[muted]{note}[/muted]")
|
|
225
274
|
|
|
226
275
|
|
|
227
276
|
def render_events_table(text: str) -> None:
|
|
@@ -505,7 +554,9 @@ def print_version(version: str) -> None:
|
|
|
505
554
|
_console.print(f"kx {version}", markup=False, highlight=False)
|
|
506
555
|
|
|
507
556
|
|
|
508
|
-
def print_help(
|
|
557
|
+
def print_help(
|
|
558
|
+
sections: list[tuple[str, list[tuple[str, str]]]], version: str | None = None
|
|
559
|
+
) -> None:
|
|
509
560
|
_console.print()
|
|
510
561
|
for line in _KX_ART:
|
|
511
562
|
_console.print(line, style="header", markup=False, highlight=False)
|
|
@@ -526,11 +577,14 @@ def print_help(sections: list[tuple[str, list[tuple[str, str]]]]) -> None:
|
|
|
526
577
|
f" [body]{'--no-color':<14}[/body] [muted]Disable styled output.[/muted]"
|
|
527
578
|
)
|
|
528
579
|
_console.print(
|
|
529
|
-
f" [body]{'--version':<14}[/body] [muted]Show the kx version and exit.[/muted]"
|
|
580
|
+
f" [body]{'-v, --version':<14}[/body] [muted]Show the kx version and exit.[/muted]"
|
|
530
581
|
)
|
|
531
582
|
_console.print(
|
|
532
583
|
f" [body]{'--help':<14}[/body] [muted]Show this message and exit.[/muted]"
|
|
533
584
|
)
|
|
585
|
+
if version:
|
|
586
|
+
_console.print()
|
|
587
|
+
_console.print(f"[muted]v{version}[/muted]")
|
|
534
588
|
|
|
535
589
|
|
|
536
590
|
_SWATCH_PARTS = (
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from kubernetes.client.exceptions import ApiException
|
|
6
|
+
|
|
7
|
+
from kx import console
|
|
8
|
+
from kx.refresh import RefreshService, StaleResourceError
|
|
9
|
+
|
|
10
|
+
_refresh_provider: Callable[[], RefreshService] | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_refresh(provider: Callable[[], RefreshService]) -> None:
|
|
14
|
+
"""Register a factory for the RefreshService used on stale-state failures.
|
|
15
|
+
|
|
16
|
+
A factory (not an instance) so the service is built per failure from the
|
|
17
|
+
caller's current service objects — tests patch those with mocks.
|
|
18
|
+
"""
|
|
19
|
+
global _refresh_provider
|
|
20
|
+
_refresh_provider = provider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _message(error: BaseException) -> str:
|
|
24
|
+
if isinstance(error, ApiException):
|
|
25
|
+
return f"Kubernetes API error: {error.status} {error.reason}"
|
|
26
|
+
return str(error)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _try_refresh(error: BaseException) -> None:
|
|
30
|
+
"""If the failure means the indexed resource is gone, re-run the entry's
|
|
31
|
+
saved query and render the fresh list so the user can pick a new index."""
|
|
32
|
+
if _refresh_provider is None:
|
|
33
|
+
return
|
|
34
|
+
refresh = _refresh_provider()
|
|
35
|
+
if not refresh.matches(error):
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
recovered = refresh.recover()
|
|
39
|
+
except (RuntimeError, ValueError, ApiException):
|
|
40
|
+
return
|
|
41
|
+
if recovered is None:
|
|
42
|
+
console.print_raw("Run 'kx get <resource>' to refresh the list.")
|
|
43
|
+
return
|
|
44
|
+
table, resource, namespace = recovered
|
|
45
|
+
console.print_raw("State was stale — refreshed, pick a new index:")
|
|
46
|
+
console.render_indexed_table(table, resource, namespace)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def handle_errors(func=None, *, refresh: bool = True):
|
|
50
|
+
"""Render expected command failures as a styled error and exit 1.
|
|
51
|
+
|
|
52
|
+
Wraps a Typer command so that `RuntimeError` (e.g. a non-zero kubectl exit or
|
|
53
|
+
missing state), `ValueError` (unsupported kind, bad argument), and the
|
|
54
|
+
Kubernetes SDK's `ApiException` are shown via `console.print_error` instead of
|
|
55
|
+
surfacing as a traceback. When the error signals a stale state entry (the
|
|
56
|
+
indexed resource no longer exists), the entry's saved query is re-run and the
|
|
57
|
+
refreshed list rendered. Commands that don't always consume an index (`get`)
|
|
58
|
+
pass `refresh=False` — message-sniffed NotFounds are then ignored, but an
|
|
59
|
+
explicit `StaleResourceError` still triggers the refresh. `typer.Exit`
|
|
60
|
+
and `typer.Abort` are not caught, so control-flow exits and confirmation
|
|
61
|
+
aborts pass through untouched. `functools.wraps` preserves `__wrapped__` so
|
|
62
|
+
Typer still reads the original signature.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def decorate(inner):
|
|
66
|
+
@functools.wraps(inner)
|
|
67
|
+
def wrapper(*args, **kwargs):
|
|
68
|
+
try:
|
|
69
|
+
return inner(*args, **kwargs)
|
|
70
|
+
except (RuntimeError, ValueError, ApiException) as e:
|
|
71
|
+
console.print_error(_message(e))
|
|
72
|
+
if refresh or isinstance(e, StaleResourceError):
|
|
73
|
+
_try_refresh(e)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
return wrapper
|
|
77
|
+
|
|
78
|
+
if func is not None:
|
|
79
|
+
return decorate(func)
|
|
80
|
+
return decorate
|
|
@@ -81,6 +81,10 @@ _PLURAL_DISPLAY: dict[Kind, str] = {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def is_kind_spelling(token: str) -> bool:
|
|
85
|
+
return token.lower() in _KIND_MAP
|
|
86
|
+
|
|
87
|
+
|
|
84
88
|
def normalize_kind(resource_type: str) -> Kind | str:
|
|
85
89
|
return _KIND_MAP.get(resource_type.lower(), resource_type)
|
|
86
90
|
|