contree-cli 0.5.0__tar.gz → 0.5.1__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.
- {contree_cli-0.5.0 → contree_cli-0.5.1}/PKG-INFO +16 -9
- {contree_cli-0.5.0 → contree_cli-0.5.1}/README.md +15 -8
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/__main__.py +17 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/agent.md +12 -7
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/arguments.py +16 -17
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/auth.py +68 -24
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/images.py +45 -7
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/ps.py +81 -73
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/client.py +41 -6
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/config.py +13 -18
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/manual.md +11 -7
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/types.py +12 -0
- contree_cli-0.5.1/contree_cli/update_check.py +165 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/pyproject.toml +1 -1
- {contree_cli-0.5.0 → contree_cli-0.5.1}/LICENSE +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/__init__.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/__init__.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/agent.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/cat.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/cd.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/cp.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/env.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/file.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/kill.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/ls.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/run.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/session.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/show.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/skill.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/tag.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/use.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/log.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/man.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/mapped_file.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/migrations.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/output.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/py.typed +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/session.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/__init__.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/completer.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/history.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/parser.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/repl.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/trie.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/skill.py +0 -0
- {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/skill_body.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: contree-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: CLI client for the ConTree sandbox platform
|
|
5
5
|
Keywords: sandbox,cli,vm,ai-agent,contree
|
|
6
6
|
Author: Dmitry Orlov
|
|
@@ -279,9 +279,9 @@ contree --help
|
|
|
279
279
|
contree auth
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree
|
|
282
|
+
You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree/auth.ini` (override the data directory via `CONTREE_HOME`).
|
|
283
283
|
|
|
284
|
-
If `NEBIUS_API_KEY` and `NEBIUS_AI_PROJECT`
|
|
284
|
+
If `--token`/`--url`/`--project` flags are omitted, `contree auth` reads `CONTREE_TOKEN` (or `NEBIUS_API_KEY`), `CONTREE_URL`, and `CONTREE_PROJECT` (or `NEBIUS_AI_PROJECT`) from the environment instead of prompting. These variables are read only during registration; runtime commands use the saved profile only.
|
|
285
285
|
|
|
286
286
|
### 2. Install agent skills (optional)
|
|
287
287
|
|
|
@@ -492,17 +492,24 @@ contree auth switch staging # switch active profile
|
|
|
492
492
|
|
|
493
493
|
### Environment variables
|
|
494
494
|
|
|
495
|
+
Read at runtime (any command):
|
|
496
|
+
|
|
495
497
|
| Variable | Purpose |
|
|
496
498
|
|---|---|
|
|
497
|
-
| `CONTREE_HOME` | Data directory (default `~/.config/contree
|
|
498
|
-
| `
|
|
499
|
-
| `CONTREE_URL` | API base URL (overrides config) |
|
|
500
|
-
| `CONTREE_PROJECT` | Project ID (overrides config) |
|
|
501
|
-
| `CONTREE_PROFILE` | Active profile name |
|
|
499
|
+
| `CONTREE_HOME` | Data directory (default `$XDG_CONFIG_HOME/contree`, or `~/.config/contree`) |
|
|
500
|
+
| `CONTREE_PROFILE` | Active profile name (selects which profile commands use) |
|
|
502
501
|
| `CONTREE_SESSION` | Explicit session key (for multi-terminal workflows) |
|
|
503
502
|
| `CONTREE_SESSION_DB` | Path to session SQLite database |
|
|
504
503
|
|
|
505
|
-
|
|
504
|
+
Read only by `contree auth` (registration-time fallbacks for omitted flags):
|
|
505
|
+
|
|
506
|
+
| Variable | Used for |
|
|
507
|
+
|---|---|
|
|
508
|
+
| `CONTREE_TOKEN` / `NEBIUS_API_KEY` | `--token` |
|
|
509
|
+
| `CONTREE_URL` | `--url` |
|
|
510
|
+
| `CONTREE_PROJECT` / `NEBIUS_AI_PROJECT` | `--project` |
|
|
511
|
+
|
|
512
|
+
Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation.
|
|
506
513
|
|
|
507
514
|
## Zero Dependencies
|
|
508
515
|
|
|
@@ -72,9 +72,9 @@ contree --help
|
|
|
72
72
|
contree auth
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree
|
|
75
|
+
You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree/auth.ini` (override the data directory via `CONTREE_HOME`).
|
|
76
76
|
|
|
77
|
-
If `NEBIUS_API_KEY` and `NEBIUS_AI_PROJECT`
|
|
77
|
+
If `--token`/`--url`/`--project` flags are omitted, `contree auth` reads `CONTREE_TOKEN` (or `NEBIUS_API_KEY`), `CONTREE_URL`, and `CONTREE_PROJECT` (or `NEBIUS_AI_PROJECT`) from the environment instead of prompting. These variables are read only during registration; runtime commands use the saved profile only.
|
|
78
78
|
|
|
79
79
|
### 2. Install agent skills (optional)
|
|
80
80
|
|
|
@@ -285,17 +285,24 @@ contree auth switch staging # switch active profile
|
|
|
285
285
|
|
|
286
286
|
### Environment variables
|
|
287
287
|
|
|
288
|
+
Read at runtime (any command):
|
|
289
|
+
|
|
288
290
|
| Variable | Purpose |
|
|
289
291
|
|---|---|
|
|
290
|
-
| `CONTREE_HOME` | Data directory (default `~/.config/contree
|
|
291
|
-
| `
|
|
292
|
-
| `CONTREE_URL` | API base URL (overrides config) |
|
|
293
|
-
| `CONTREE_PROJECT` | Project ID (overrides config) |
|
|
294
|
-
| `CONTREE_PROFILE` | Active profile name |
|
|
292
|
+
| `CONTREE_HOME` | Data directory (default `$XDG_CONFIG_HOME/contree`, or `~/.config/contree`) |
|
|
293
|
+
| `CONTREE_PROFILE` | Active profile name (selects which profile commands use) |
|
|
295
294
|
| `CONTREE_SESSION` | Explicit session key (for multi-terminal workflows) |
|
|
296
295
|
| `CONTREE_SESSION_DB` | Path to session SQLite database |
|
|
297
296
|
|
|
298
|
-
|
|
297
|
+
Read only by `contree auth` (registration-time fallbacks for omitted flags):
|
|
298
|
+
|
|
299
|
+
| Variable | Used for |
|
|
300
|
+
|---|---|
|
|
301
|
+
| `CONTREE_TOKEN` / `NEBIUS_API_KEY` | `--token` |
|
|
302
|
+
| `CONTREE_URL` | `--url` |
|
|
303
|
+
| `CONTREE_PROJECT` / `NEBIUS_AI_PROJECT` | `--project` |
|
|
304
|
+
|
|
305
|
+
Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation.
|
|
299
306
|
|
|
300
307
|
## Zero Dependencies
|
|
301
308
|
|
|
@@ -2,6 +2,7 @@ import contextvars
|
|
|
2
2
|
import logging
|
|
3
3
|
import sys
|
|
4
4
|
from collections.abc import Callable
|
|
5
|
+
from contextlib import suppress
|
|
5
6
|
from dataclasses import replace
|
|
6
7
|
|
|
7
8
|
import contree_cli.config as config_mod
|
|
@@ -12,6 +13,7 @@ from contree_cli.config import SETTINGS, Config
|
|
|
12
13
|
from contree_cli.log import setup_logging
|
|
13
14
|
from contree_cli.output import FORMATTERS
|
|
14
15
|
from contree_cli.session import SessionStore, get_session_key
|
|
16
|
+
from contree_cli.update_check import UpdateChecker
|
|
15
17
|
|
|
16
18
|
log = logging.getLogger(__name__)
|
|
17
19
|
|
|
@@ -27,6 +29,21 @@ def main() -> None:
|
|
|
27
29
|
args = parser.parse_args()
|
|
28
30
|
setup_logging(level=getattr(logging, args.log_level.upper(), logging.INFO))
|
|
29
31
|
|
|
32
|
+
# Update check runs only after argparse so it skips --help / --version
|
|
33
|
+
# / no-command paths and so the warning respects --log-level. refresh()
|
|
34
|
+
# is best-effort; check() is a pure predicate.
|
|
35
|
+
checker = UpdateChecker()
|
|
36
|
+
with suppress(Exception):
|
|
37
|
+
checker.refresh()
|
|
38
|
+
if not checker.is_latest():
|
|
39
|
+
log.warning(
|
|
40
|
+
"A new version of contree-cli is available: %s (installed: %s)."
|
|
41
|
+
" Upgrade with `uv tool install -U contree-cli` or"
|
|
42
|
+
" `pip install -U contree-cli`.",
|
|
43
|
+
checker.state.latest_version,
|
|
44
|
+
checker.current_version,
|
|
45
|
+
)
|
|
46
|
+
|
|
30
47
|
config_mod.CONFIG_FILE = args.config_path
|
|
31
48
|
config_mod.CONFIG_DIR = args.config_path.parent
|
|
32
49
|
|
|
@@ -328,18 +328,23 @@ Per-command -p is useful for cross-project operations:
|
|
|
328
328
|
contree -p project-a images --prefix=base
|
|
329
329
|
contree -p project-b images import tag:base:latest
|
|
330
330
|
|
|
331
|
-
Data directory: ~/.config/contree
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
331
|
+
Data directory: $XDG_CONFIG_HOME/contree/ (or ~/.config/contree/)
|
|
332
|
+
auth.ini profile credentials (mode 0600)
|
|
333
|
+
cli.ini optional CLI defaults
|
|
334
|
+
cli/sessions/{profile}.db per-profile sessions, history, cache
|
|
335
|
+
cli/skills.db installed agent skill registry
|
|
336
|
+
cli/version_check.json cached PyPI update-check state
|
|
335
337
|
|
|
336
338
|
Environment variables:
|
|
337
339
|
CONTREE_HOME data directory override
|
|
338
|
-
|
|
339
|
-
CONTREE_URL API URL (overrides config)
|
|
340
|
-
CONTREE_PROFILE active profile (overrides config)
|
|
340
|
+
CONTREE_PROFILE active profile (selects which profile commands use)
|
|
341
341
|
CONTREE_SESSION explicit session key
|
|
342
342
|
|
|
343
|
+
Read only by `contree auth` (registration-time fallbacks):
|
|
344
|
+
CONTREE_TOKEN / NEBIUS_API_KEY token when --token is omitted
|
|
345
|
+
CONTREE_URL URL when --url is omitted
|
|
346
|
+
CONTREE_PROJECT / NEBIUS_AI_PROJECT project ID when --project is omitted
|
|
347
|
+
|
|
343
348
|
More: contree auth --help
|
|
344
349
|
|
|
345
350
|
All commands
|
|
@@ -71,20 +71,19 @@ mutating commands (change remote or local session state):
|
|
|
71
71
|
contree use IMAGE | run -- CMD | file edit PATH | file cp SRC DEST
|
|
72
72
|
contree tag UUID TAG | kill UUID | cd PATH | session checkout BRANCH
|
|
73
73
|
|
|
74
|
-
environment variables
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
NEBIUS_AI_PROJECT Fallback project ID for IAM auth registration
|
|
74
|
+
environment variables:
|
|
75
|
+
CONTREE_PROFILE Active config profile (selects which profile to use)
|
|
76
|
+
CONTREE_SESSION Explicit session name (for multi-terminal workflows).
|
|
77
|
+
If unset, contree auto-generates <cwd>+<8hex> (derived
|
|
78
|
+
from profile+ppid+tty); export your own for stable
|
|
79
|
+
reuse. You can also pass -S/--session instead.
|
|
80
|
+
CONTREE_SESSION_DB Path to session SQLite database
|
|
81
|
+
CONTREE_NO_UPDATE_CHECK Set to any value to disable PyPI update checks
|
|
82
|
+
|
|
83
|
+
registration-time fallbacks (only read by `contree auth`, not at runtime):
|
|
84
|
+
CONTREE_TOKEN / NEBIUS_API_KEY Token used when --token is omitted
|
|
85
|
+
CONTREE_URL URL used when --url is omitted
|
|
86
|
+
CONTREE_PROJECT / NEBIUS_AI_PROJECT Project ID used when --project is omitted
|
|
88
87
|
"""
|
|
89
88
|
|
|
90
89
|
DESCRIPTION = """\
|
|
@@ -121,7 +120,7 @@ parser.add_argument(
|
|
|
121
120
|
parser.add_argument(
|
|
122
121
|
*FLAGS["token"],
|
|
123
122
|
default=None,
|
|
124
|
-
help="API token (overrides
|
|
123
|
+
help="API token (overrides profile for this invocation)",
|
|
125
124
|
)
|
|
126
125
|
|
|
127
126
|
|
|
@@ -133,12 +132,12 @@ parser.add_argument(
|
|
|
133
132
|
*FLAGS["url"],
|
|
134
133
|
default=None,
|
|
135
134
|
type=_strip_trailing_slashes,
|
|
136
|
-
help="API base URL (overrides
|
|
135
|
+
help="API base URL (overrides profile for this invocation)",
|
|
137
136
|
)
|
|
138
137
|
parser.add_argument(
|
|
139
138
|
*FLAGS["project"],
|
|
140
139
|
default=None,
|
|
141
|
-
help="Project ID (overrides
|
|
140
|
+
help="Project ID (overrides profile for this invocation)",
|
|
142
141
|
)
|
|
143
142
|
parser.add_argument(
|
|
144
143
|
*FLAGS["config"],
|
|
@@ -8,9 +8,13 @@ Supports two auth types:
|
|
|
8
8
|
iam (default) — bearer token + project ID, default URL provided
|
|
9
9
|
jwt (legacy) — bearer token only, URL must be specified
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
NEBIUS_API_KEY used
|
|
13
|
-
|
|
11
|
+
Environment variable fallbacks during registration:
|
|
12
|
+
CONTREE_TOKEN / NEBIUS_API_KEY used when --token is omitted
|
|
13
|
+
CONTREE_URL used when --url is omitted
|
|
14
|
+
CONTREE_PROJECT / NEBIUS_AI_PROJECT used when --project is omitted (IAM)
|
|
15
|
+
|
|
16
|
+
Other commands ignore these variables; only ``contree auth`` reads
|
|
17
|
+
them. ``CONTREE_PROFILE`` selects the profile for any command.
|
|
14
18
|
|
|
15
19
|
Subcommands:
|
|
16
20
|
profiles List saved profiles (* marks active)
|
|
@@ -22,6 +26,7 @@ from __future__ import annotations
|
|
|
22
26
|
import argparse
|
|
23
27
|
import getpass
|
|
24
28
|
import hashlib
|
|
29
|
+
import json
|
|
25
30
|
import logging
|
|
26
31
|
import os
|
|
27
32
|
from dataclasses import dataclass
|
|
@@ -35,6 +40,7 @@ from contree_cli.types import FLAGS
|
|
|
35
40
|
logger = logging.getLogger(__name__)
|
|
36
41
|
PROFILE_CHECK_TIMEOUT = 2.0
|
|
37
42
|
PROFILE_CHECK_CONCURRENCY = 4
|
|
43
|
+
REQUIRED_PERMISSION = "list"
|
|
38
44
|
|
|
39
45
|
EPILOG = """\
|
|
40
46
|
for coding agents:
|
|
@@ -169,6 +175,24 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
|
169
175
|
return cmd_auth, AuthArgs
|
|
170
176
|
|
|
171
177
|
|
|
178
|
+
def env_fallback(names: tuple[str, ...], *, what: str) -> str | None:
|
|
179
|
+
for name in names:
|
|
180
|
+
value = os.environ.get(name)
|
|
181
|
+
if value:
|
|
182
|
+
logger.info("Using %s from %s", what, name)
|
|
183
|
+
return value
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def check_permission(payload: object, permission: str) -> bool:
|
|
188
|
+
if not isinstance(payload, dict):
|
|
189
|
+
return False
|
|
190
|
+
perms = payload.get("permissions")
|
|
191
|
+
if not isinstance(perms, dict):
|
|
192
|
+
return False
|
|
193
|
+
return bool(perms.get(permission))
|
|
194
|
+
|
|
195
|
+
|
|
172
196
|
def cmd_auth(args: AuthArgs) -> int | None:
|
|
173
197
|
cfg = Config()
|
|
174
198
|
exists = args.profile in cfg
|
|
@@ -188,18 +212,16 @@ def cmd_auth(args: AuthArgs) -> int | None:
|
|
|
188
212
|
print("Aborted.")
|
|
189
213
|
return 1
|
|
190
214
|
|
|
191
|
-
# Token: --token > NEBIUS_API_KEY > interactive prompt
|
|
192
|
-
token = args.token
|
|
215
|
+
# Token: --token > CONTREE_TOKEN > NEBIUS_API_KEY > interactive prompt
|
|
216
|
+
token = args.token or env_fallback(
|
|
217
|
+
("CONTREE_TOKEN", "NEBIUS_API_KEY"),
|
|
218
|
+
what="token",
|
|
219
|
+
)
|
|
193
220
|
if token is None:
|
|
194
|
-
|
|
195
|
-
if nebius_key:
|
|
196
|
-
logger.info("Using token from NEBIUS_API_KEY")
|
|
197
|
-
token = nebius_key
|
|
198
|
-
else:
|
|
199
|
-
token = getpass.getpass("Token: ")
|
|
221
|
+
token = getpass.getpass("Token: ")
|
|
200
222
|
|
|
201
|
-
# URL: --url > type-specific default > interactive prompt
|
|
202
|
-
url = args.url
|
|
223
|
+
# URL: --url > CONTREE_URL > type-specific default > interactive prompt
|
|
224
|
+
url = args.url or env_fallback(("CONTREE_URL",), what="URL")
|
|
203
225
|
if url is None:
|
|
204
226
|
if args.auth_type == AuthType.IAM:
|
|
205
227
|
url = Config.DEFAULT_IAM_URL
|
|
@@ -209,17 +231,15 @@ def cmd_auth(args: AuthArgs) -> int | None:
|
|
|
209
231
|
logger.error("URL is required for JWT auth")
|
|
210
232
|
return 1
|
|
211
233
|
|
|
212
|
-
# Project (IAM only): --project > NEBIUS_AI_PROJECT >
|
|
234
|
+
# Project (IAM only): --project > CONTREE_PROJECT > NEBIUS_AI_PROJECT > prompt
|
|
213
235
|
project: str | None = None
|
|
214
236
|
if args.auth_type == AuthType.IAM:
|
|
215
|
-
project = args.project
|
|
237
|
+
project = args.project or env_fallback(
|
|
238
|
+
("CONTREE_PROJECT", "NEBIUS_AI_PROJECT"),
|
|
239
|
+
what="project",
|
|
240
|
+
)
|
|
216
241
|
if project is None:
|
|
217
|
-
|
|
218
|
-
if nebius_project:
|
|
219
|
-
logger.info("Using project from NEBIUS_AI_PROJECT")
|
|
220
|
-
project = nebius_project
|
|
221
|
-
else:
|
|
222
|
-
project = input("Project ID: ").strip()
|
|
242
|
+
project = input("Project ID: ").strip()
|
|
223
243
|
profile = ConfigProfile(
|
|
224
244
|
name=args.profile,
|
|
225
245
|
token=token,
|
|
@@ -235,15 +255,33 @@ def cmd_auth(args: AuthArgs) -> int | None:
|
|
|
235
255
|
return 1
|
|
236
256
|
|
|
237
257
|
try:
|
|
238
|
-
client.get("/v1/whoami")
|
|
258
|
+
resp = client.get("/v1/whoami")
|
|
259
|
+
whoami = json.loads(resp.read() or b"{}")
|
|
239
260
|
except ApiError as exc:
|
|
240
261
|
# Logs the API error message, not the token itself.
|
|
241
262
|
# nosemgrep: python-logger-credential-disclosure
|
|
242
263
|
logger.error("Token verification failed: %s. Profile not changed.", exc)
|
|
243
264
|
return 1
|
|
265
|
+
except ValueError as exc:
|
|
266
|
+
logger.error("Could not parse /v1/whoami response: %s", exc)
|
|
267
|
+
return 1
|
|
268
|
+
|
|
269
|
+
if not check_permission(whoami, REQUIRED_PERMISSION):
|
|
270
|
+
project_label = profile.project or profile.url
|
|
271
|
+
logger.warning(
|
|
272
|
+
"Warning: token is valid but sandboxes are disabled on %s"
|
|
273
|
+
" (no %r permission). The profile will be saved but no commands"
|
|
274
|
+
" will work until the service is enabled.",
|
|
275
|
+
project_label,
|
|
276
|
+
REQUIRED_PERMISSION,
|
|
277
|
+
)
|
|
244
278
|
|
|
245
279
|
cfg[args.profile] = profile
|
|
246
|
-
logger.info(
|
|
280
|
+
logger.info(
|
|
281
|
+
"auth accepted, profile %r saved to -> %s",
|
|
282
|
+
args.profile,
|
|
283
|
+
cfg.path,
|
|
284
|
+
)
|
|
247
285
|
return None
|
|
248
286
|
|
|
249
287
|
|
|
@@ -284,11 +322,17 @@ def cmd_list(args: ProfilesArgs) -> None:
|
|
|
284
322
|
|
|
285
323
|
try:
|
|
286
324
|
resp = client.get("/v1/whoami")
|
|
287
|
-
resp.read()
|
|
325
|
+
payload = resp.read()
|
|
288
326
|
except TimeoutError:
|
|
289
327
|
return profile, "timeout"
|
|
290
328
|
except Exception:
|
|
291
329
|
return profile, "error"
|
|
330
|
+
try:
|
|
331
|
+
whoami = json.loads(payload or b"{}")
|
|
332
|
+
except ValueError:
|
|
333
|
+
return profile, "error"
|
|
334
|
+
if not check_permission(whoami, REQUIRED_PERMISSION):
|
|
335
|
+
return profile, "inactive"
|
|
292
336
|
return profile, "ok"
|
|
293
337
|
|
|
294
338
|
formatter = FORMATTER.get()
|
|
@@ -25,11 +25,13 @@ from contree_cli.types import (
|
|
|
25
25
|
isoformat_datetime,
|
|
26
26
|
parse_datetime,
|
|
27
27
|
parse_interval,
|
|
28
|
+
positive_int,
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger(__name__)
|
|
31
32
|
|
|
32
|
-
PAGE_SIZE =
|
|
33
|
+
PAGE_SIZE = 1000
|
|
34
|
+
LIMIT_DEFAULT = 3000
|
|
33
35
|
TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
|
|
34
36
|
DOCKER_HUB = "docker.io"
|
|
35
37
|
|
|
@@ -77,6 +79,7 @@ class ImagesArgs(ArgumentsProtocol):
|
|
|
77
79
|
all_images: bool = False
|
|
78
80
|
since: datetime | None = None
|
|
79
81
|
until: datetime | None = None
|
|
82
|
+
limit: int = LIMIT_DEFAULT
|
|
80
83
|
|
|
81
84
|
@classmethod
|
|
82
85
|
def from_args(cls, ns: argparse.Namespace) -> ImagesArgs:
|
|
@@ -86,6 +89,7 @@ class ImagesArgs(ArgumentsProtocol):
|
|
|
86
89
|
all_images=getattr(ns, "all_images", False),
|
|
87
90
|
since=getattr(ns, "since", None),
|
|
88
91
|
until=getattr(ns, "until", None),
|
|
92
|
+
limit=getattr(ns, "limit", LIMIT_DEFAULT),
|
|
89
93
|
)
|
|
90
94
|
|
|
91
95
|
|
|
@@ -189,6 +193,12 @@ def _add_list_args(p: argparse.ArgumentParser) -> None:
|
|
|
189
193
|
type=parse_interval,
|
|
190
194
|
help="Show images before. " + str(parse_interval.__doc__),
|
|
191
195
|
)
|
|
196
|
+
p.add_argument(
|
|
197
|
+
*FLAGS["limit"],
|
|
198
|
+
type=positive_int,
|
|
199
|
+
default=LIMIT_DEFAULT,
|
|
200
|
+
help="Stop after this many images and warn if more are available",
|
|
201
|
+
)
|
|
192
202
|
|
|
193
203
|
|
|
194
204
|
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
@@ -249,7 +259,7 @@ def cmd_images(args: ImagesArgs) -> None:
|
|
|
249
259
|
client = CLIENT.get()
|
|
250
260
|
formatter = FORMATTER.get()
|
|
251
261
|
|
|
252
|
-
base_params: dict[str, str] = {
|
|
262
|
+
base_params: dict[str, str] = {}
|
|
253
263
|
if args.prefix is not None:
|
|
254
264
|
base_params["tag"] = args.prefix
|
|
255
265
|
if args.uuid is not None:
|
|
@@ -262,13 +272,19 @@ def cmd_images(args: ImagesArgs) -> None:
|
|
|
262
272
|
base_params["until"] = isoformat_datetime(args.until)
|
|
263
273
|
|
|
264
274
|
offset = 0
|
|
265
|
-
|
|
266
|
-
|
|
275
|
+
emitted = 0
|
|
276
|
+
while emitted < args.limit:
|
|
277
|
+
page_size = min(PAGE_SIZE, args.limit - emitted)
|
|
278
|
+
params = {
|
|
279
|
+
**base_params,
|
|
280
|
+
"offset": str(offset),
|
|
281
|
+
"limit": str(page_size),
|
|
282
|
+
}
|
|
267
283
|
resp = client.get("/v1/images", params=params)
|
|
268
284
|
data = json.loads(resp.read())
|
|
269
285
|
images = data["images"]
|
|
270
286
|
if not images:
|
|
271
|
-
|
|
287
|
+
return
|
|
272
288
|
for image in images:
|
|
273
289
|
created_at = parse_datetime(image["created_at"])
|
|
274
290
|
formatter(
|
|
@@ -276,9 +292,31 @@ def cmd_images(args: ImagesArgs) -> None:
|
|
|
276
292
|
created_at=created_at,
|
|
277
293
|
tag=image.get("tag") or "",
|
|
278
294
|
)
|
|
279
|
-
|
|
280
|
-
|
|
295
|
+
emitted += len(images)
|
|
296
|
+
if len(images) < page_size:
|
|
297
|
+
return
|
|
281
298
|
offset += len(images)
|
|
299
|
+
if emitted < args.limit:
|
|
300
|
+
logger.info(
|
|
301
|
+
"Fetched %d images so far... (press Ctrl+C to break)",
|
|
302
|
+
emitted,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Hit the limit. Probe one extra record (offset=emitted, limit=1) to
|
|
306
|
+
# detect truncation without re-fetching a full page.
|
|
307
|
+
probe_params = {**base_params, "offset": str(offset), "limit": "1"}
|
|
308
|
+
resp = client.get("/v1/images", params=probe_params)
|
|
309
|
+
data = json.loads(resp.read())
|
|
310
|
+
if data.get("images"):
|
|
311
|
+
# Flush buffered output (e.g. TableFormatter) before the warning
|
|
312
|
+
# so the truncation note appears AFTER the listing on screen.
|
|
313
|
+
formatter.flush()
|
|
314
|
+
logger.warning(
|
|
315
|
+
"Output truncated at --limit=%d images; more results are"
|
|
316
|
+
" available. Raise --limit or narrow with"
|
|
317
|
+
" --prefix/--since/--until.",
|
|
318
|
+
args.limit,
|
|
319
|
+
)
|
|
282
320
|
|
|
283
321
|
|
|
284
322
|
def _parse_explicit_tag(ref: str) -> tuple[str, str | None]:
|
|
@@ -13,14 +13,19 @@ import argparse
|
|
|
13
13
|
import itertools
|
|
14
14
|
import json
|
|
15
15
|
import logging
|
|
16
|
-
from collections.abc import Iterator
|
|
17
16
|
from dataclasses import dataclass
|
|
18
17
|
from datetime import datetime, timedelta
|
|
19
18
|
from typing import Any
|
|
20
19
|
|
|
21
20
|
from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
|
|
22
21
|
from contree_cli.output import OutputFormatter
|
|
23
|
-
from contree_cli.types import
|
|
22
|
+
from contree_cli.types import (
|
|
23
|
+
FLAGS,
|
|
24
|
+
isoformat_datetime,
|
|
25
|
+
parse_datetime,
|
|
26
|
+
parse_interval,
|
|
27
|
+
positive_int,
|
|
28
|
+
)
|
|
24
29
|
|
|
25
30
|
logger = logging.getLogger(__name__)
|
|
26
31
|
|
|
@@ -103,7 +108,7 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
|
103
108
|
|
|
104
109
|
p.add_argument(
|
|
105
110
|
*FLAGS["show_max"],
|
|
106
|
-
type=
|
|
111
|
+
type=positive_int,
|
|
107
112
|
default=1000,
|
|
108
113
|
help=(
|
|
109
114
|
"Show at most this many operations, useful"
|
|
@@ -114,64 +119,26 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
|
114
119
|
return cmd_ps, PsArgs
|
|
115
120
|
|
|
116
121
|
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"limit": str(page_size),
|
|
133
|
-
"offset": str(offset),
|
|
134
|
-
}
|
|
135
|
-
if status:
|
|
136
|
-
params["status"] = status
|
|
137
|
-
if kind:
|
|
138
|
-
params["kind"] = kind
|
|
139
|
-
if since:
|
|
140
|
-
params["since"] = since
|
|
141
|
-
if until:
|
|
142
|
-
params["until"] = until
|
|
143
|
-
resp = client.get("/v1/operations", params=params)
|
|
144
|
-
operations = json.loads(resp.read())
|
|
145
|
-
|
|
146
|
-
if not operations:
|
|
147
|
-
break
|
|
148
|
-
|
|
149
|
-
offset += len(operations)
|
|
150
|
-
for op in operations:
|
|
151
|
-
if show_max is not None and counter >= show_max:
|
|
152
|
-
logger.warning(
|
|
153
|
-
"Reached show_max limit of %d, for see more increase --show-max",
|
|
154
|
-
show_max,
|
|
155
|
-
)
|
|
156
|
-
return
|
|
157
|
-
counter += 1
|
|
158
|
-
yield dict(
|
|
159
|
-
uuid=op["uuid"],
|
|
160
|
-
status=op["status"],
|
|
161
|
-
kind=op["kind"],
|
|
162
|
-
created_at=parse_datetime(op["created_at"]),
|
|
163
|
-
duration=timedelta(seconds=op["duration"])
|
|
164
|
-
if op.get("duration") is not None
|
|
165
|
-
else None,
|
|
166
|
-
error=op.get("error") or "",
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
if len(operations) < page_size:
|
|
170
|
-
break
|
|
122
|
+
def emit_op(formatter: OutputFormatter, op: dict[str, Any], *, quiet: bool) -> None:
|
|
123
|
+
row = dict(
|
|
124
|
+
uuid=op["uuid"],
|
|
125
|
+
status=op["status"],
|
|
126
|
+
kind=op["kind"],
|
|
127
|
+
created_at=parse_datetime(op["created_at"]),
|
|
128
|
+
duration=timedelta(seconds=op["duration"])
|
|
129
|
+
if op.get("duration") is not None
|
|
130
|
+
else None,
|
|
131
|
+
error=op.get("error") or "",
|
|
132
|
+
)
|
|
133
|
+
if quiet:
|
|
134
|
+
print(row["uuid"])
|
|
135
|
+
else:
|
|
136
|
+
formatter(**row)
|
|
171
137
|
|
|
172
138
|
|
|
173
139
|
def cmd_ps(args: PsArgs) -> None:
|
|
174
140
|
formatter: OutputFormatter = FORMATTER.get()
|
|
141
|
+
client = CLIENT.get()
|
|
175
142
|
|
|
176
143
|
status: str | None = None
|
|
177
144
|
if args.status is not None:
|
|
@@ -182,21 +149,62 @@ def cmd_ps(args: PsArgs) -> None:
|
|
|
182
149
|
elif not args.all:
|
|
183
150
|
status = "EXECUTING"
|
|
184
151
|
|
|
185
|
-
|
|
152
|
+
base_params: dict[str, str] = {}
|
|
153
|
+
if status:
|
|
154
|
+
base_params["status"] = status
|
|
155
|
+
if args.kind:
|
|
156
|
+
base_params["kind"] = args.kind
|
|
186
157
|
if args.since is not None:
|
|
187
|
-
since = isoformat_datetime(args.since)
|
|
188
|
-
until: str | None = None
|
|
158
|
+
base_params["since"] = isoformat_datetime(args.since)
|
|
189
159
|
if args.until is not None:
|
|
190
|
-
until = isoformat_datetime(args.until)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
160
|
+
base_params["until"] = isoformat_datetime(args.until)
|
|
161
|
+
|
|
162
|
+
limit = args.show_max
|
|
163
|
+
offset = 0
|
|
164
|
+
emitted = 0
|
|
165
|
+
hit_limit = False
|
|
166
|
+
|
|
167
|
+
while limit is None or emitted < limit:
|
|
168
|
+
page_size = PAGE_SIZE if limit is None else min(PAGE_SIZE, limit - emitted)
|
|
169
|
+
params = {
|
|
170
|
+
**base_params,
|
|
171
|
+
"offset": str(offset),
|
|
172
|
+
"limit": str(page_size),
|
|
173
|
+
}
|
|
174
|
+
resp = client.get("/v1/operations", params=params)
|
|
175
|
+
operations = json.loads(resp.read())
|
|
176
|
+
if not operations:
|
|
177
|
+
return
|
|
178
|
+
for op in operations:
|
|
179
|
+
if limit is not None and emitted >= limit:
|
|
180
|
+
hit_limit = True
|
|
181
|
+
break
|
|
182
|
+
emit_op(formatter, op, quiet=args.quiet)
|
|
183
|
+
emitted += 1
|
|
184
|
+
if hit_limit:
|
|
185
|
+
break
|
|
186
|
+
if len(operations) < page_size:
|
|
187
|
+
return
|
|
188
|
+
offset += len(operations)
|
|
189
|
+
if limit is None or emitted < limit:
|
|
190
|
+
logger.info(
|
|
191
|
+
"Fetched %d operations so far... (press Ctrl+C to break)",
|
|
192
|
+
emitted,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if limit is None:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# Hit the limit. Probe one extra record (offset=emitted, limit=1) to
|
|
199
|
+
# detect truncation without re-fetching a full page.
|
|
200
|
+
probe_params = {**base_params, "offset": str(emitted), "limit": "1"}
|
|
201
|
+
resp = client.get("/v1/operations", params=probe_params)
|
|
202
|
+
operations = json.loads(resp.read())
|
|
203
|
+
if operations:
|
|
204
|
+
formatter.flush()
|
|
205
|
+
logger.warning(
|
|
206
|
+
"Output truncated at --show-max=%d operations; more results"
|
|
207
|
+
" are available. Raise --show-max or filter with"
|
|
208
|
+
" --status/--kind/--since/--until.",
|
|
209
|
+
limit,
|
|
210
|
+
)
|
|
@@ -9,7 +9,7 @@ import platform
|
|
|
9
9
|
import sys
|
|
10
10
|
import time
|
|
11
11
|
from abc import ABC, abstractmethod
|
|
12
|
-
from collections.abc import Iterator
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
13
|
from importlib.metadata import PackageNotFoundError, version
|
|
14
14
|
from typing import IO, cast
|
|
15
15
|
from urllib.parse import urlencode, urlsplit
|
|
@@ -21,7 +21,7 @@ log = logging.getLogger(__name__)
|
|
|
21
21
|
RETRY_DELAYS = (1, 2, 4, 5, 10, 10, 10)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def cli_version() -> str:
|
|
25
25
|
try:
|
|
26
26
|
return version("contree-cli")
|
|
27
27
|
except PackageNotFoundError:
|
|
@@ -29,12 +29,43 @@ def _cli_version() -> str:
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
CLI_USER_AGENT = (
|
|
32
|
-
f"contree-cli/{
|
|
32
|
+
f"contree-cli/{cli_version()} "
|
|
33
33
|
f"Python/{'.'.join(map(str, sys.version_info))} "
|
|
34
34
|
f"{platform.platform()} "
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
class HeaderFormatter:
|
|
39
|
+
"""Lazy redactor for HTTP headers, formats only on emit."""
|
|
40
|
+
|
|
41
|
+
SENSITIVE_HEADERS = frozenset(
|
|
42
|
+
{
|
|
43
|
+
"authorization",
|
|
44
|
+
"proxy-authorization",
|
|
45
|
+
"cookie",
|
|
46
|
+
"set-cookie",
|
|
47
|
+
"x-api-key",
|
|
48
|
+
"x-auth-token",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
headers: dict[str, str] | list[tuple[str, str]],
|
|
55
|
+
) -> None:
|
|
56
|
+
self.headers = headers
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
items: Iterable[tuple[str, str]] = (
|
|
60
|
+
self.headers.items() if isinstance(self.headers, dict) else self.headers
|
|
61
|
+
)
|
|
62
|
+
redacted = {
|
|
63
|
+
k: "<redacted>" if k.lower() in self.SENSITIVE_HEADERS else v
|
|
64
|
+
for k, v in items
|
|
65
|
+
}
|
|
66
|
+
return repr(redacted)
|
|
67
|
+
|
|
68
|
+
|
|
38
69
|
class BodyFormatter:
|
|
39
70
|
"""Lazy %s-arg for logging HTTP bodies — formats only on emit."""
|
|
40
71
|
|
|
@@ -186,9 +217,10 @@ class ContreeClient(ABC):
|
|
|
186
217
|
attempts = len(RETRY_DELAYS) + 1
|
|
187
218
|
|
|
188
219
|
log.debug(
|
|
189
|
-
"%s %s body=%s",
|
|
220
|
+
"%s %s headers=%s body=%s",
|
|
190
221
|
method,
|
|
191
222
|
full_path,
|
|
223
|
+
HeaderFormatter(merged),
|
|
192
224
|
BodyFormatter(body, content_type=merged.get("Content-Type", "")),
|
|
193
225
|
)
|
|
194
226
|
|
|
@@ -220,24 +252,27 @@ class ContreeClient(ABC):
|
|
|
220
252
|
|
|
221
253
|
if 200 <= resp.status < 300:
|
|
222
254
|
log.debug(
|
|
223
|
-
"%s %s -> %d %s",
|
|
255
|
+
"%s %s -> %d %s headers=%s",
|
|
224
256
|
method,
|
|
225
257
|
full_path,
|
|
226
258
|
resp.status,
|
|
227
259
|
resp.reason,
|
|
260
|
+
HeaderFormatter(list(resp.getheaders())),
|
|
228
261
|
)
|
|
229
262
|
if log.isEnabledFor(logging.DEBUG):
|
|
230
263
|
return self.log_and_buffer(method, full_path, resp)
|
|
231
264
|
return resp
|
|
232
265
|
|
|
266
|
+
resp_headers = list(resp.getheaders())
|
|
233
267
|
resp_body = resp.read().decode("utf-8", errors="replace")
|
|
234
268
|
log.debug(
|
|
235
|
-
"%s %s -> %d %s (%dB)",
|
|
269
|
+
"%s %s -> %d %s (%dB) headers=%s",
|
|
236
270
|
method,
|
|
237
271
|
full_path,
|
|
238
272
|
resp.status,
|
|
239
273
|
resp.reason,
|
|
240
274
|
len(resp_body),
|
|
275
|
+
HeaderFormatter(resp_headers),
|
|
241
276
|
)
|
|
242
277
|
log.debug(
|
|
243
278
|
"%s %s response body: %s",
|
|
@@ -94,6 +94,10 @@ class Config(MutableMapping[str, ConfigProfile]):
|
|
|
94
94
|
self.__active: str = "default"
|
|
95
95
|
self._load()
|
|
96
96
|
|
|
97
|
+
@property
|
|
98
|
+
def path(self) -> Path:
|
|
99
|
+
return self.__path
|
|
100
|
+
|
|
97
101
|
# -- persistence ---------------------------------------------------------
|
|
98
102
|
|
|
99
103
|
def _load(self) -> None:
|
|
@@ -191,32 +195,23 @@ class Config(MutableMapping[str, ConfigProfile]):
|
|
|
191
195
|
self._save()
|
|
192
196
|
|
|
193
197
|
def resolve(self, profile_override: str | None = None) -> ConfigProfile:
|
|
194
|
-
"""Resolve the active profile
|
|
198
|
+
"""Resolve the active profile by name.
|
|
195
199
|
|
|
196
200
|
Priority: *profile_override* > ``CONTREE_PROFILE`` > config default.
|
|
197
|
-
|
|
198
|
-
|
|
201
|
+
Credentials come strictly from the saved profile; runtime
|
|
202
|
+
commands do not read tokens, URLs, or project IDs from the
|
|
203
|
+
environment. To register/refresh credentials from env vars use
|
|
204
|
+
``contree auth``.
|
|
199
205
|
"""
|
|
200
206
|
name = profile_override or os.environ.get("CONTREE_PROFILE") or self.__active
|
|
201
|
-
env_token = os.environ.get("CONTREE_TOKEN")
|
|
202
|
-
env_url = os.environ.get("CONTREE_URL")
|
|
203
|
-
env_project = os.environ.get("CONTREE_PROJECT")
|
|
204
|
-
|
|
205
207
|
if name in self.__profiles:
|
|
206
|
-
|
|
207
|
-
return ConfigProfile(
|
|
208
|
-
name=name,
|
|
209
|
-
token=env_token or p.token,
|
|
210
|
-
url=env_url or p.url,
|
|
211
|
-
auth_type=p.auth_type,
|
|
212
|
-
project=env_project or p.project,
|
|
213
|
-
)
|
|
208
|
+
return self.__profiles[name]
|
|
214
209
|
return ConfigProfile(
|
|
215
210
|
name=name,
|
|
216
|
-
token=
|
|
217
|
-
url=
|
|
211
|
+
token=None,
|
|
212
|
+
url="",
|
|
218
213
|
auth_type=AuthType.JWT,
|
|
219
|
-
project=
|
|
214
|
+
project=None,
|
|
220
215
|
)
|
|
221
216
|
|
|
222
217
|
def switch(self, name: str) -> None:
|
|
@@ -198,9 +198,11 @@ Profiles
|
|
|
198
198
|
|
|
199
199
|
Each profile has its own session database.
|
|
200
200
|
|
|
201
|
-
Auth fallback:
|
|
202
|
-
|
|
203
|
-
`
|
|
201
|
+
Auth fallback: when `--token`/`--url`/`--project` flags are omitted,
|
|
202
|
+
`contree auth` reads `CONTREE_TOKEN` (or `NEBIUS_API_KEY`),
|
|
203
|
+
`CONTREE_URL`, and `CONTREE_PROJECT` (or `NEBIUS_AI_PROJECT`) from
|
|
204
|
+
the environment. These are used only during registration; runtime
|
|
205
|
+
commands read credentials strictly from the saved profile.
|
|
204
206
|
|
|
205
207
|
More: contree auth --help
|
|
206
208
|
|
|
@@ -218,12 +220,14 @@ override with $CONTREE_HOME.
|
|
|
218
220
|
Environment variables:
|
|
219
221
|
CONTREE_HOME data directory
|
|
220
222
|
XDG_CONFIG_HOME XDG base config dir, used to derive default CONTREE_HOME
|
|
221
|
-
|
|
222
|
-
CONTREE_URL API URL (overrides config)
|
|
223
|
-
CONTREE_PROJECT project ID (overrides config)
|
|
224
|
-
CONTREE_PROFILE active profile
|
|
223
|
+
CONTREE_PROFILE active profile (selects which profile commands use)
|
|
225
224
|
CONTREE_SESSION explicit session key
|
|
226
225
|
|
|
226
|
+
Registration-time fallbacks (read only by `contree auth`):
|
|
227
|
+
CONTREE_TOKEN / NEBIUS_API_KEY token when --token is omitted
|
|
228
|
+
CONTREE_URL URL when --url is omitted
|
|
229
|
+
CONTREE_PROJECT / NEBIUS_AI_PROJECT project ID when --project is omitted
|
|
230
|
+
|
|
227
231
|
More: contree --help
|
|
228
232
|
|
|
229
233
|
All commands
|
|
@@ -62,6 +62,7 @@ FLAGS: Mapping[str, tuple[str, ...]] = MappingProxyType(
|
|
|
62
62
|
"uuid": ("-i", "--uuid"),
|
|
63
63
|
"username": ("--username",),
|
|
64
64
|
"password": ("--password",),
|
|
65
|
+
"limit": ("--limit",),
|
|
65
66
|
# use
|
|
66
67
|
"new": ("-N", "--new"),
|
|
67
68
|
# session
|
|
@@ -89,6 +90,17 @@ class ArgumentsFormatter(argparse.RawDescriptionHelpFormatter):
|
|
|
89
90
|
return help_text
|
|
90
91
|
|
|
91
92
|
|
|
93
|
+
def positive_int(value: str) -> int:
|
|
94
|
+
"""argparse type for flags that must be at least 1."""
|
|
95
|
+
try:
|
|
96
|
+
n = int(value)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
|
|
99
|
+
if n < 1:
|
|
100
|
+
raise argparse.ArgumentTypeError(f"must be >= 1, got {n}")
|
|
101
|
+
return n
|
|
102
|
+
|
|
103
|
+
|
|
92
104
|
def get_command_docs(setup_fn: SetupFn) -> tuple[str | None, str | None]:
|
|
93
105
|
"""Extract description and epilog from the module that defines *setup_fn*.
|
|
94
106
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""PyPI update check, rate-limited to once per day.
|
|
2
|
+
|
|
3
|
+
State file at ``$CONTREE_HOME/cli/version_check.json``::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"last_check": 1762555200,
|
|
7
|
+
"latest_version": "0.5.0"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
``last_check`` is a Unix epoch timestamp; storing seconds keeps the
|
|
11
|
+
freshness check trivial (one subtraction) and immune to timezone /
|
|
12
|
+
ISO-format quirks.
|
|
13
|
+
|
|
14
|
+
Network errors, malformed cache files, and parse failures are swallowed:
|
|
15
|
+
the update check must never break a user's command.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
from contextlib import suppress
|
|
26
|
+
from dataclasses import asdict, dataclass
|
|
27
|
+
from datetime import timedelta
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from contree_cli import config
|
|
31
|
+
from contree_cli.client import CLI_USER_AGENT, cli_version
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class UpdateState:
|
|
36
|
+
last_check: int = 0
|
|
37
|
+
latest_version: str = ""
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_file(cls, path: Path) -> UpdateState:
|
|
41
|
+
try:
|
|
42
|
+
with path.open() as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
return cls(
|
|
45
|
+
last_check=int(data["last_check"]),
|
|
46
|
+
latest_version=str(data["latest_version"]),
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
return cls()
|
|
50
|
+
|
|
51
|
+
def to_file(self, path: Path) -> None:
|
|
52
|
+
with suppress(OSError):
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
path.write_text(json.dumps(asdict(self), indent=1))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UpdateChecker:
|
|
58
|
+
PYPI_URL = "https://pypi.org/pypi/contree-cli/json"
|
|
59
|
+
CHECK_INTERVAL = timedelta(days=1)
|
|
60
|
+
NETWORK_TIMEOUT = 2.0
|
|
61
|
+
OPT_OUT_ENV = "CONTREE_NO_UPDATE_CHECK"
|
|
62
|
+
STATE_PATH = config.CONTREE_HOME / "cli" / "version_check.json"
|
|
63
|
+
# Capture leading digits of each dot-separated component; anything
|
|
64
|
+
# past the digits (``a1``, ``-rc.1``, etc.) marks a pre-release.
|
|
65
|
+
COMPONENT_REGEX = re.compile(r"\d+")
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
state_path: Path = STATE_PATH,
|
|
71
|
+
current_version: str = cli_version(),
|
|
72
|
+
) -> None:
|
|
73
|
+
self.state_path = state_path
|
|
74
|
+
self.current_version = current_version
|
|
75
|
+
# ``state`` holds whatever we know about PyPI's latest version.
|
|
76
|
+
# Default sentinel ("", last_check=0) means "no cache yet" —
|
|
77
|
+
# is_latest() treats it as up-to-date so callers don't warn.
|
|
78
|
+
self.state: UpdateState = UpdateState()
|
|
79
|
+
|
|
80
|
+
def parse_version(self, value: str) -> tuple[tuple[int, int], ...]:
|
|
81
|
+
"""Parse ``value`` into a sortable tuple of ``(number, rank)``.
|
|
82
|
+
|
|
83
|
+
``rank`` is ``1`` for a clean numeric component and ``0`` for a
|
|
84
|
+
pre-release suffix (``a1``, ``-rc.1``, …). With this encoding,
|
|
85
|
+
``0.4.2a1`` < ``0.4.2`` < ``0.4.21`` as expected. Components with
|
|
86
|
+
no digits at all are dropped.
|
|
87
|
+
"""
|
|
88
|
+
parts: list[tuple[int, int]] = []
|
|
89
|
+
for raw in value.split("."):
|
|
90
|
+
match = self.COMPONENT_REGEX.search(raw)
|
|
91
|
+
if not match:
|
|
92
|
+
continue
|
|
93
|
+
tail = raw[match.end() :]
|
|
94
|
+
parts.append((int(match.group()), 0 if tail else 1))
|
|
95
|
+
return tuple(parts)
|
|
96
|
+
|
|
97
|
+
def fetch_latest_version(self) -> str | None:
|
|
98
|
+
try:
|
|
99
|
+
request = urllib.request.Request(
|
|
100
|
+
self.PYPI_URL,
|
|
101
|
+
headers={
|
|
102
|
+
"User-Agent": CLI_USER_AGENT,
|
|
103
|
+
"Accept": "application/json",
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
with urllib.request.urlopen( # nosemgrep
|
|
107
|
+
request, timeout=self.NETWORK_TIMEOUT
|
|
108
|
+
) as resp:
|
|
109
|
+
payload = json.loads(resp.read())
|
|
110
|
+
except Exception:
|
|
111
|
+
return None
|
|
112
|
+
info = payload.get("info") if isinstance(payload, dict) else None
|
|
113
|
+
if not isinstance(info, dict):
|
|
114
|
+
return None
|
|
115
|
+
version = info.get("version")
|
|
116
|
+
return version if isinstance(version, str) else None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def enabled(self) -> bool:
|
|
120
|
+
return self.OPT_OUT_ENV not in os.environ and self.current_version != "editable"
|
|
121
|
+
|
|
122
|
+
def is_cache_fresh(self, state: UpdateState) -> bool:
|
|
123
|
+
"""True if ``state.last_check`` is within ``CHECK_INTERVAL``."""
|
|
124
|
+
return time.time() - state.last_check < self.CHECK_INTERVAL.total_seconds()
|
|
125
|
+
|
|
126
|
+
def refresh(self) -> None:
|
|
127
|
+
"""Load the cache, refetch from PyPI if stale, persist new state.
|
|
128
|
+
|
|
129
|
+
Populates ``self.state`` with whatever we know after this call.
|
|
130
|
+
:meth:`is_latest` then decides whether to warn based purely on
|
|
131
|
+
in-memory state — no further file IO.
|
|
132
|
+
"""
|
|
133
|
+
if not self.enabled:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
self.state = UpdateState.from_file(self.state_path)
|
|
137
|
+
if self.is_cache_fresh(self.state):
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
latest = self.fetch_latest_version()
|
|
141
|
+
if latest is None:
|
|
142
|
+
# Network failed; keep whatever was cached.
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
self.state = UpdateState(
|
|
146
|
+
last_check=int(time.time()),
|
|
147
|
+
latest_version=latest,
|
|
148
|
+
)
|
|
149
|
+
self.state.to_file(self.state_path)
|
|
150
|
+
|
|
151
|
+
def is_latest(self) -> bool:
|
|
152
|
+
"""Return True if the installed version is at or ahead of the cached
|
|
153
|
+
``latest_version``.
|
|
154
|
+
|
|
155
|
+
Returns True when checks are disabled or the cached
|
|
156
|
+
``latest_version`` is the empty sentinel — callers default to
|
|
157
|
+
"no warning" in those cases. Pure decision based on in-memory
|
|
158
|
+
state populated by :meth:`refresh`; never touches the network
|
|
159
|
+
or filesystem.
|
|
160
|
+
"""
|
|
161
|
+
if not self.enabled or not self.state.latest_version:
|
|
162
|
+
return True
|
|
163
|
+
return self.parse_version(self.current_version) >= self.parse_version(
|
|
164
|
+
self.state.latest_version,
|
|
165
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|