levers9 0.2.1__tar.gz → 0.2.2__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.
- {levers9-0.2.1 → levers9-0.2.2}/PKG-INFO +1 -1
- {levers9-0.2.1 → levers9-0.2.2}/pyproject.toml +1 -1
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/update_check.py +30 -6
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/client.py +17 -3
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/config.py +11 -1
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/PKG-INFO +1 -1
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/SOURCES.txt +2 -0
- levers9-0.2.2/tests/test_client_base_url.py +38 -0
- levers9-0.2.2/tests/test_config.py +60 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_update_check.py +47 -9
- {levers9-0.2.1 → levers9-0.2.2}/README.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/setup.cfg +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/__main__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/capacity.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/container.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/runner.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/utils.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/blueprint.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/box.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/endpoint.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/bot.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/marker.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/types.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/signal.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/integrations/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/integrations/fastmcp.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/integrations/vllm.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/job.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/map.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/mixins.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/output.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/queue.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/sandbox.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/service.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/shell.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/volume.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/agent/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/agent/server.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/agent/tools.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/aio.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/channel.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/config.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/container.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/database.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/deployment.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/dev.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/disk.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/extraclick.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/llm.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/login.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/machine.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/machine_format.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/main.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/mcp.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/pool.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/run.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/secret.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/serve.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/shell.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/skills.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/task.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/token.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/volume.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/worker.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/worker_management.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/deployment.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/task.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/blueprint/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/bot/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/box/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/controlplane/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/disk/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/endpoint/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/google/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/google/api/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/job/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/map/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/output/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/queue/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/secret/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/shell/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/signal/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/types/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/volume/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/env.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/exceptions.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/integrations.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/logging.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/middleware.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/multipart.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/bot/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/bot/transition.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/common.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/container.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/endpoint.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/job.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/queue.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/serve.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/schema.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/README.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-blueprints/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-boxes/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-cli/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-crons/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-dashboard/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-data-lab/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-endpoints/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-gpu/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-jobs/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-llm-serving/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-mcp-server/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-pipelines/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-queues/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-rl-arena/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-sandboxes/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-volumes-secrets/SKILL.md +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/sync.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/terminal.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/type.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/utils.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/_meta.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/gitignore.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/pathspec.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/pattern.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/__init__.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/gitwildmatch.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/py.typed +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/util.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9/whereami.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/dependency_links.txt +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/entry_points.txt +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/requires.txt +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/top_level.txt +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_capacity.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_cli.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_container_runner.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_deployment.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_env.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_filesyncer.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_function.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_function_runner.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_image.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_machine_cli.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_map.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_multipart.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_pod_instance.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_pool.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_runner.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_convenience.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_docker_manager.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_log_streaming_integration.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_process_stream.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_service.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_shell.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_skills.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_task_queue.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_utils.py +0 -0
- {levers9-0.2.1 → levers9-0.2.2}/tests/test_volumes.py +0 -0
|
@@ -22,6 +22,7 @@ from typing import Optional, Tuple
|
|
|
22
22
|
PACKAGE = "levers9"
|
|
23
23
|
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
|
|
24
24
|
CHECK_INTERVAL_S = 24 * 60 * 60
|
|
25
|
+
NOTIFY_INTERVAL_S = 24 * 60 * 60
|
|
25
26
|
NETWORK_TIMEOUT_S = 2.0
|
|
26
27
|
|
|
27
28
|
|
|
@@ -36,15 +37,26 @@ def _read_cache() -> dict:
|
|
|
36
37
|
return {}
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
def _write_cache(latest: str) -> None:
|
|
40
|
+
def _write_cache(latest: str, **extra) -> None:
|
|
40
41
|
try:
|
|
41
42
|
p = _cache_path()
|
|
42
43
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
-
|
|
44
|
+
data = {**_read_cache(), "latest": latest, "checked_at": time.time(), **extra}
|
|
45
|
+
p.write_text(json.dumps(data))
|
|
44
46
|
except OSError:
|
|
45
47
|
pass # read-only home, no disk: not worth a word
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
def _mark_notified() -> None:
|
|
51
|
+
try:
|
|
52
|
+
p = _cache_path()
|
|
53
|
+
data = {**_read_cache(), "notified_at": time.time()}
|
|
54
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
p.write_text(json.dumps(data))
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
48
60
|
def _installed_version() -> Optional[str]:
|
|
49
61
|
try:
|
|
50
62
|
from importlib.metadata import version
|
|
@@ -138,7 +150,14 @@ def _refresh_in_background() -> None:
|
|
|
138
150
|
|
|
139
151
|
|
|
140
152
|
def disabled() -> bool:
|
|
141
|
-
|
|
153
|
+
if os.getenv("LEVERS9_NO_UPDATE_CHECK") or os.getenv("CI"):
|
|
154
|
+
return True
|
|
155
|
+
# Nothing is read by a person when output is being captured, and a notice written into a pipe is
|
|
156
|
+
# corruption rather than help: `levers9 ls > out.json` must produce only the json.
|
|
157
|
+
try:
|
|
158
|
+
return not sys.stderr.isatty()
|
|
159
|
+
except Exception:
|
|
160
|
+
return True
|
|
142
161
|
|
|
143
162
|
|
|
144
163
|
def notice() -> Optional[str]:
|
|
@@ -158,6 +177,11 @@ def notice() -> Optional[str]:
|
|
|
158
177
|
if not latest or not is_newer(latest, installed):
|
|
159
178
|
return None
|
|
160
179
|
|
|
180
|
+
# Say it once a day, not once a command. Someone running twenty commands does not need telling twenty
|
|
181
|
+
# times, and a notice that repeats is one people learn to read past.
|
|
182
|
+
if time.time() - float(cache.get("notified_at") or 0) < NOTIFY_INTERVAL_S:
|
|
183
|
+
return None
|
|
184
|
+
|
|
161
185
|
return f"levers9 {latest} is available (you have {installed}) · uv tool upgrade levers9"
|
|
162
186
|
|
|
163
187
|
|
|
@@ -167,8 +191,8 @@ def print_notice() -> None:
|
|
|
167
191
|
line = notice()
|
|
168
192
|
if not line:
|
|
169
193
|
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
# stderr, so it never lands in output a caller is parsing
|
|
195
|
+
print(f"\n\033[2m{line}\033[0m", file=sys.stderr)
|
|
196
|
+
_mark_notified()
|
|
173
197
|
except Exception:
|
|
174
198
|
pass
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
-
from ..config import get_config_context, get_settings
|
|
8
|
+
from ..config import DEFAULT_API_HOST, get_config_context, get_settings
|
|
9
9
|
from ..env import is_remote
|
|
10
10
|
from ..exceptions import VolumeUploadError, WorkspaceNotFoundError
|
|
11
11
|
from . import get, make_request
|
|
@@ -42,11 +42,25 @@ class Client:
|
|
|
42
42
|
if not self.token:
|
|
43
43
|
self.token = config_context.token
|
|
44
44
|
|
|
45
|
+
# api_host is a dataclass default, not read from the environment, so it is 0.0.0.0 unless a
|
|
46
|
+
# caller passes one — which meant every REST call went to 0.0.0.0:1994 and only worked when the
|
|
47
|
+
# controlplane happened to be local. Prefer an explicit override, then the context the CLI and
|
|
48
|
+
# the dashboard already write, and keep the old default as the last resort.
|
|
45
49
|
if not controlplane_host:
|
|
46
|
-
self.controlplane_host =
|
|
50
|
+
self.controlplane_host = (
|
|
51
|
+
os.environ.get("LEVERS9_API_HOST")
|
|
52
|
+
or (config_context.controlplane_host if settings.api_host == DEFAULT_API_HOST else settings.api_host)
|
|
53
|
+
or settings.api_host
|
|
54
|
+
)
|
|
47
55
|
|
|
48
56
|
if not controlplane_port:
|
|
49
|
-
|
|
57
|
+
override = os.environ.get("LEVERS9_API_PORT")
|
|
58
|
+
if override:
|
|
59
|
+
self.controlplane_port = int(override)
|
|
60
|
+
elif config_context.controlplane_port == 443:
|
|
61
|
+
self.controlplane_port = 443 # TLS front door: REST and gRPC share it
|
|
62
|
+
else:
|
|
63
|
+
self.controlplane_port = settings.api_port
|
|
50
64
|
|
|
51
65
|
if not self.tls and self.controlplane_port == 443:
|
|
52
66
|
self.tls = True
|
|
@@ -71,9 +71,19 @@ class ConfigContext:
|
|
|
71
71
|
controlplane_host: Optional[str] = None
|
|
72
72
|
controlplane_port: Optional[int] = None
|
|
73
73
|
|
|
74
|
+
# A config written before the rename spells these gateway_*. Every load comes through from_dict, and
|
|
75
|
+
# unknown keys are dropped here, so without this the old names vanish on read - and the next command
|
|
76
|
+
# that saves writes the file back with the host blank, quietly losing every context but the one in use.
|
|
77
|
+
_RENAMED = {"gateway_host": "controlplane_host", "gateway_port": "controlplane_port"}
|
|
78
|
+
|
|
74
79
|
@classmethod
|
|
75
80
|
def from_dict(cls, data: Mapping[str, Any]) -> "ConfigContext":
|
|
76
|
-
|
|
81
|
+
fields = inspect.signature(cls).parameters
|
|
82
|
+
out = {k: v for k, v in data.items() if k in fields}
|
|
83
|
+
for was, now in cls._RENAMED.items():
|
|
84
|
+
if data.get(was) and not out.get(now):
|
|
85
|
+
out[now] = data[was]
|
|
86
|
+
return cls(**out)
|
|
77
87
|
|
|
78
88
|
def to_dict(self) -> MutableMapping[str, Any]:
|
|
79
89
|
return {k: ("" if not v else v) for k, v in asdict(self).items()}
|
|
@@ -140,6 +140,8 @@ src/levers9/vendor/pathspec/patterns/__init__.py
|
|
|
140
140
|
src/levers9/vendor/pathspec/patterns/gitwildmatch.py
|
|
141
141
|
tests/test_capacity.py
|
|
142
142
|
tests/test_cli.py
|
|
143
|
+
tests/test_client_base_url.py
|
|
144
|
+
tests/test_config.py
|
|
143
145
|
tests/test_container_runner.py
|
|
144
146
|
tests/test_deployment.py
|
|
145
147
|
tests/test_env.py
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The REST client must reach the configured controlplane, not 0.0.0.0."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseUrl(unittest.TestCase):
|
|
9
|
+
def _client(self, env=None, ctx_host="levers9-controlplane", ctx_port=1993):
|
|
10
|
+
from levers9.client.client import Client
|
|
11
|
+
from levers9.config import ConfigContext
|
|
12
|
+
|
|
13
|
+
with mock.patch.dict(os.environ, env or {}, clear=False), mock.patch(
|
|
14
|
+
"levers9.client.client.get_config_context",
|
|
15
|
+
return_value=ConfigContext(
|
|
16
|
+
token="t", controlplane_host=ctx_host, controlplane_port=ctx_port
|
|
17
|
+
),
|
|
18
|
+
), mock.patch(
|
|
19
|
+
"levers9.client.client.Client._load_workspace", lambda self: None
|
|
20
|
+
), mock.patch("levers9.client.client.is_remote", return_value=False):
|
|
21
|
+
return Client(token="t")
|
|
22
|
+
|
|
23
|
+
def test_uses_the_configured_host_not_the_default(self):
|
|
24
|
+
c = self._client()
|
|
25
|
+
self.assertIn("levers9-controlplane", c.base_url)
|
|
26
|
+
self.assertNotIn("0.0.0.0", c.base_url)
|
|
27
|
+
|
|
28
|
+
def test_explicit_env_wins(self):
|
|
29
|
+
c = self._client(env={"LEVERS9_API_HOST": "rest.example", "LEVERS9_API_PORT": "8080"})
|
|
30
|
+
self.assertIn("rest.example:8080", c.base_url)
|
|
31
|
+
|
|
32
|
+
def test_tls_front_door_keeps_443(self):
|
|
33
|
+
c = self._client(ctx_host="levers9-grpc.uselevers.com", ctx_port=443)
|
|
34
|
+
self.assertTrue(c.base_url.startswith("https://"), c.base_url)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The config file survives a round trip, including one written before the gateway -> controlplane rename."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from levers9.config import ConfigContext, load_config, save_config
|
|
7
|
+
|
|
8
|
+
OLD_FORMAT = """\
|
|
9
|
+
[default]
|
|
10
|
+
token = tok-default
|
|
11
|
+
gateway_host = levers9-grpc.uselevers.com
|
|
12
|
+
gateway_port = 443
|
|
13
|
+
|
|
14
|
+
[local]
|
|
15
|
+
token = tok-local
|
|
16
|
+
gateway_host = localhost
|
|
17
|
+
gateway_port = 1993
|
|
18
|
+
|
|
19
|
+
[staging]
|
|
20
|
+
token = tok-staging
|
|
21
|
+
gateway_host = staging.uselevers.com
|
|
22
|
+
gateway_port = 443
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_old_key_names_are_read(tmp_path: Path):
|
|
27
|
+
path = tmp_path / "config"
|
|
28
|
+
path.write_text(OLD_FORMAT)
|
|
29
|
+
|
|
30
|
+
contexts = load_config(path)
|
|
31
|
+
|
|
32
|
+
assert contexts["local"].controlplane_host == "localhost"
|
|
33
|
+
# configparser hands back strings, which is what the new spelling yields too; use_ssl() takes either.
|
|
34
|
+
assert str(contexts["local"].controlplane_port) == "1993"
|
|
35
|
+
assert contexts["staging"].controlplane_host == "staging.uselevers.com"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_saving_does_not_blank_the_contexts_it_did_not_touch(tmp_path: Path):
|
|
39
|
+
"""The regression: unknown keys were dropped on load, so the next save wrote every host back empty."""
|
|
40
|
+
path = tmp_path / "config"
|
|
41
|
+
path.write_text(OLD_FORMAT)
|
|
42
|
+
|
|
43
|
+
contexts = load_config(path)
|
|
44
|
+
save_config(contexts, path)
|
|
45
|
+
|
|
46
|
+
parser = configparser.ConfigParser(default_section="default")
|
|
47
|
+
parser.read(path)
|
|
48
|
+
|
|
49
|
+
assert parser["local"]["controlplane_host"] == "localhost"
|
|
50
|
+
assert parser["local"]["controlplane_port"] == "1993"
|
|
51
|
+
assert parser["staging"]["controlplane_host"] == "staging.uselevers.com"
|
|
52
|
+
assert parser["staging"]["token"] == "tok-staging"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_the_new_spelling_wins_when_a_config_carries_both(tmp_path: Path):
|
|
56
|
+
ctx = ConfigContext.from_dict(
|
|
57
|
+
{"token": "t", "controlplane_host": "new.example", "gateway_host": "old.example"}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
assert ctx.controlplane_host == "new.example"
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""The behaviours that matter: never blocks, never fails a command, never nags wrongly."""
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
3
7
|
from pathlib import Path
|
|
4
8
|
from unittest import mock
|
|
5
9
|
|
|
@@ -8,7 +12,12 @@ from levers9.cli import update_check as uc
|
|
|
8
12
|
|
|
9
13
|
class VersionCompare(unittest.TestCase):
|
|
10
14
|
def test_newer_is_detected(self):
|
|
11
|
-
for latest, installed in [
|
|
15
|
+
for latest, installed in [
|
|
16
|
+
("0.2.0", "0.1.272"),
|
|
17
|
+
("1.0.0", "0.9.9"),
|
|
18
|
+
("0.2.1", "0.2.0"),
|
|
19
|
+
("0.10.0", "0.9.0"),
|
|
20
|
+
]:
|
|
12
21
|
self.assertTrue(uc.is_newer(latest, installed), f"{latest} > {installed}")
|
|
13
22
|
|
|
14
23
|
def test_same_or_older_is_not(self):
|
|
@@ -25,25 +34,32 @@ class VersionCompare(unittest.TestCase):
|
|
|
25
34
|
|
|
26
35
|
class Notice(unittest.TestCase):
|
|
27
36
|
def setUp(self):
|
|
28
|
-
self.tmp = Path(os.environ
|
|
37
|
+
self.tmp = Path(os.environ.get("TMPDIR", "/tmp")) / f"uc-{time.time_ns()}"
|
|
29
38
|
self.tmp.mkdir(parents=True, exist_ok=True)
|
|
30
39
|
self.patches = [
|
|
31
40
|
mock.patch.object(uc, "_cache_path", lambda: self.tmp / "update-check.json"),
|
|
32
41
|
mock.patch.object(uc, "_installed_version", lambda: "0.1.272"),
|
|
33
42
|
mock.patch.dict(os.environ, {"LEVERS9_NO_UPDATE_CHECK": "", "CI": ""}, clear=False),
|
|
43
|
+
mock.patch("sys.stderr.isatty", lambda: True),
|
|
34
44
|
]
|
|
35
|
-
for
|
|
45
|
+
for patch in self.patches:
|
|
46
|
+
patch.start()
|
|
36
47
|
|
|
37
48
|
def tearDown(self):
|
|
38
|
-
for
|
|
49
|
+
for patch in self.patches:
|
|
50
|
+
patch.stop()
|
|
39
51
|
|
|
40
52
|
def _cache(self, latest, age_s=0):
|
|
41
|
-
(self.tmp / "update-check.json").write_text(
|
|
53
|
+
(self.tmp / "update-check.json").write_text(
|
|
54
|
+
json.dumps({"latest": latest, "checked_at": time.time() - age_s})
|
|
55
|
+
)
|
|
42
56
|
|
|
43
57
|
def test_fresh_cache_with_newer_version_notifies(self):
|
|
44
58
|
self._cache("0.2.0")
|
|
45
59
|
n = uc.notice()
|
|
46
|
-
self.assertIn("0.2.0", n)
|
|
60
|
+
self.assertIn("0.2.0", n)
|
|
61
|
+
self.assertIn("0.1.272", n)
|
|
62
|
+
self.assertIn("upgrade", n)
|
|
47
63
|
|
|
48
64
|
def test_same_version_is_silent(self):
|
|
49
65
|
self._cache("0.1.272")
|
|
@@ -53,7 +69,7 @@ class Notice(unittest.TestCase):
|
|
|
53
69
|
started = time.monotonic()
|
|
54
70
|
with mock.patch.object(uc, "_refresh_in_background") as bg:
|
|
55
71
|
self.assertIsNone(uc.notice())
|
|
56
|
-
bg.assert_called_once()
|
|
72
|
+
bg.assert_called_once() # refresh is scheduled for next time
|
|
57
73
|
self.assertLess(time.monotonic() - started, 0.1, "notice() must not wait on anything")
|
|
58
74
|
|
|
59
75
|
def test_stale_cache_still_uses_what_it_has(self):
|
|
@@ -73,9 +89,31 @@ class Notice(unittest.TestCase):
|
|
|
73
89
|
with mock.patch.object(uc, "_refresh_in_background"):
|
|
74
90
|
self.assertIsNone(uc.notice())
|
|
75
91
|
|
|
92
|
+
def test_repeats_at_most_once_a_day(self):
|
|
93
|
+
self._cache("0.2.0")
|
|
94
|
+
self.assertIsNotNone(uc.notice(), "the first look should speak")
|
|
95
|
+
uc._mark_notified()
|
|
96
|
+
self.assertIsNone(uc.notice(), "the second should not")
|
|
97
|
+
|
|
98
|
+
def test_silent_when_output_is_captured(self):
|
|
99
|
+
self._cache("0.2.0")
|
|
100
|
+
with mock.patch("sys.stderr.isatty", lambda: False):
|
|
101
|
+
self.assertIsNone(uc.notice(), "a pipe must never receive a notice")
|
|
102
|
+
|
|
103
|
+
def test_notice_goes_to_stderr_not_stdout(self):
|
|
104
|
+
import contextlib
|
|
105
|
+
import io
|
|
106
|
+
|
|
107
|
+
self._cache("0.2.0")
|
|
108
|
+
out, err = io.StringIO(), io.StringIO()
|
|
109
|
+
with contextlib.redirect_stdout(out), mock.patch("sys.stderr", err):
|
|
110
|
+
with mock.patch("sys.stderr.isatty", lambda: True, create=True):
|
|
111
|
+
uc.print_notice()
|
|
112
|
+
self.assertEqual(out.getvalue(), "", "stdout must stay clean for callers parsing it")
|
|
113
|
+
|
|
76
114
|
def test_print_notice_never_raises(self):
|
|
77
115
|
with mock.patch.object(uc, "notice", side_effect=RuntimeError("boom")):
|
|
78
|
-
uc.print_notice()
|
|
116
|
+
uc.print_notice() # must swallow: a notice is never worth failing a command
|
|
79
117
|
|
|
80
118
|
def test_unreachable_pypi_is_silent(self):
|
|
81
119
|
with mock.patch("urllib.request.urlopen", side_effect=OSError("no network")):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|