levers9 0.2.0__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.0 → levers9-0.2.2}/PKG-INFO +2 -2
- {levers9-0.2.0 → levers9-0.2.2}/pyproject.toml +2 -2
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/main.py +9 -0
- levers9-0.2.2/src/levers9/cli/update_check.py +198 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/client.py +17 -3
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/config.py +11 -1
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/PKG-INFO +2 -2
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/SOURCES.txt +4 -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.2/tests/test_update_check.py +124 -0
- {levers9-0.2.0 → levers9-0.2.2}/README.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/setup.cfg +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/__main__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/capacity.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/container.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/runner.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/utils.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/blueprint.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/box.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/endpoint.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/bot.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/marker.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/types.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/signal.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/integrations/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/integrations/fastmcp.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/integrations/vllm.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/job.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/map.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/mixins.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/output.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/queue.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/sandbox.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/service.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/shell.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/volume.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/agent/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/agent/server.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/agent/tools.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/aio.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/channel.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/config.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/container.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/database.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/deployment.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/dev.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/disk.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/extraclick.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/llm.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/login.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/machine.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/machine_format.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/mcp.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/pool.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/run.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/secret.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/serve.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/shell.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/skills.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/task.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/token.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/volume.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/worker.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/worker_management.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/deployment.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/task.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/blueprint/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/bot/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/box/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/controlplane/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/disk/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/endpoint/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/google/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/google/api/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/job/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/map/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/output/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/queue/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/secret/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/shell/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/signal/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/types/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/volume/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/env.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/exceptions.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/integrations.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/logging.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/middleware.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/multipart.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/bot/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/bot/transition.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/common.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/container.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/endpoint.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/job.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/queue.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/serve.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/schema.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/README.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-blueprints/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-boxes/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-cli/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-crons/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-dashboard/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-data-lab/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-endpoints/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-gpu/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-jobs/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-llm-serving/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-mcp-server/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-pipelines/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-queues/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-rl-arena/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-sandboxes/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-volumes-secrets/SKILL.md +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/sync.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/terminal.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/type.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/utils.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/_meta.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/gitignore.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/pathspec.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/pattern.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/__init__.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/gitwildmatch.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/py.typed +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/util.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9/whereami.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/dependency_links.txt +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/entry_points.txt +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/requires.txt +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/top_level.txt +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_capacity.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_cli.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_container_runner.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_deployment.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_env.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_filesyncer.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_function.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_function_runner.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_image.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_machine_cli.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_map.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_multipart.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_pod_instance.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_pool.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_runner.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_convenience.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_docker_manager.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_log_streaming_integration.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_process_stream.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_service.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_shell.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_skills.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_task_queue.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_utils.py +0 -0
- {levers9-0.2.0 → levers9-0.2.2}/tests/test_volumes.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: levers9
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: levers9 SDK and CLI: run
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: levers9 SDK and CLI: run jobs, endpoints, queues, crons, boxes and GPU work on a levers9 cluster
|
|
5
5
|
Author-email: Levers AI Lab <hello@uselevers.com>
|
|
6
6
|
Requires-Python: <4.0,>=3.8
|
|
7
7
|
Requires-Dist: typeguard<3.0.0,>=2.13.3; python_version < "3.9"
|
|
@@ -27,8 +27,8 @@ dependencies = [
|
|
|
27
27
|
"pyyaml<7.0.0,>=6.0.0",
|
|
28
28
|
]
|
|
29
29
|
name = "levers9"
|
|
30
|
-
version = "0.2.
|
|
31
|
-
description = "levers9 SDK and CLI: run
|
|
30
|
+
version = "0.2.2"
|
|
31
|
+
description = "levers9 SDK and CLI: run jobs, endpoints, queues, crons, boxes and GPU work on a levers9 cluster"
|
|
32
32
|
|
|
33
33
|
[project.scripts]
|
|
34
34
|
levers9 = "levers9.cli.main:start"
|
|
@@ -143,6 +143,15 @@ def start():
|
|
|
143
143
|
"""Used as entrypoint in Poetry"""
|
|
144
144
|
cli = load_cli()
|
|
145
145
|
|
|
146
|
+
# atexit, rather than wrapping the call: click owns its exit codes, --help handling and error
|
|
147
|
+
# rendering, and taking that over with standalone_mode=False silently breaks subcommand help.
|
|
148
|
+
# This runs after the command, however it ended, and cannot change its result.
|
|
149
|
+
import atexit
|
|
150
|
+
|
|
151
|
+
from .update_check import print_notice
|
|
152
|
+
|
|
153
|
+
atexit.register(print_notice)
|
|
154
|
+
|
|
146
155
|
try:
|
|
147
156
|
cli()
|
|
148
157
|
except grpc.RpcError as error:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Tell someone when a newer levers9 is out, without ever making them wait for it.
|
|
2
|
+
|
|
3
|
+
The check never happens on the path the user is waiting on. A command reads a small cache file and, if it
|
|
4
|
+
already knows about a newer release, prints one line. Refreshing that cache is left to a daemon thread whose
|
|
5
|
+
result lands in time for the *next* command, so a cold or slow network costs nothing now. Everything here is
|
|
6
|
+
best-effort: a failure to reach PyPI, a corrupt cache or a read-only home directory all end in silence,
|
|
7
|
+
because an update notice is never worth failing a command over.
|
|
8
|
+
|
|
9
|
+
Off with LEVERS9_NO_UPDATE_CHECK=1, and off in CI by default.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional, Tuple
|
|
21
|
+
|
|
22
|
+
PACKAGE = "levers9"
|
|
23
|
+
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
|
|
24
|
+
CHECK_INTERVAL_S = 24 * 60 * 60
|
|
25
|
+
NOTIFY_INTERVAL_S = 24 * 60 * 60
|
|
26
|
+
NETWORK_TIMEOUT_S = 2.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cache_path() -> Path:
|
|
30
|
+
return Path("~/.levers9").expanduser() / "update-check.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_cache() -> dict:
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(_cache_path().read_text())
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_cache(latest: str, **extra) -> None:
|
|
41
|
+
try:
|
|
42
|
+
p = _cache_path()
|
|
43
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
data = {**_read_cache(), "latest": latest, "checked_at": time.time(), **extra}
|
|
45
|
+
p.write_text(json.dumps(data))
|
|
46
|
+
except OSError:
|
|
47
|
+
pass # read-only home, no disk: not worth a word
|
|
48
|
+
|
|
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
|
+
|
|
60
|
+
def _installed_version() -> Optional[str]:
|
|
61
|
+
try:
|
|
62
|
+
from importlib.metadata import version
|
|
63
|
+
|
|
64
|
+
return version(PACKAGE)
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse(v: str) -> Tuple:
|
|
70
|
+
"""Compare releases numerically, and sort any pre-release below its own final version."""
|
|
71
|
+
core = v.split("+", 1)[0]
|
|
72
|
+
for sep in ("a", "b", "rc", ".dev"):
|
|
73
|
+
if sep in core:
|
|
74
|
+
core = core.split(sep, 1)[0]
|
|
75
|
+
pre = True
|
|
76
|
+
break
|
|
77
|
+
else:
|
|
78
|
+
pre = False
|
|
79
|
+
parts = []
|
|
80
|
+
for piece in core.split("."):
|
|
81
|
+
try:
|
|
82
|
+
parts.append(int(piece))
|
|
83
|
+
except ValueError:
|
|
84
|
+
parts.append(0)
|
|
85
|
+
while len(parts) < 3:
|
|
86
|
+
parts.append(0)
|
|
87
|
+
return (*parts[:3], 0 if pre else 1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_newer(latest: str, installed: str) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
return _parse(latest) > _parse(installed)
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch_latest() -> Optional[str]:
|
|
98
|
+
try:
|
|
99
|
+
import urllib.request
|
|
100
|
+
|
|
101
|
+
req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"})
|
|
102
|
+
with urllib.request.urlopen(req, timeout=NETWORK_TIMEOUT_S) as r:
|
|
103
|
+
return json.load(r)["info"]["version"]
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Run in a process of its own, detached, rather than a thread: most commands finish in well under the
|
|
109
|
+
# time a request to PyPI takes, and a daemon thread dies with the interpreter, so the cache would never
|
|
110
|
+
# actually be written and the notice would never appear. This is why update-notifier and friends reach
|
|
111
|
+
# for a child process too.
|
|
112
|
+
_REFRESH_SCRIPT = """
|
|
113
|
+
import json, os, time, urllib.request
|
|
114
|
+
try:
|
|
115
|
+
req = urllib.request.Request(%(url)r, headers={"Accept": "application/json"})
|
|
116
|
+
with urllib.request.urlopen(req, timeout=%(timeout)r) as r:
|
|
117
|
+
latest = json.load(r)["info"]["version"]
|
|
118
|
+
p = %(cache)r
|
|
119
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
120
|
+
tmp = p + ".tmp"
|
|
121
|
+
with open(tmp, "w") as f:
|
|
122
|
+
json.dump({"latest": latest, "checked_at": time.time()}, f)
|
|
123
|
+
os.replace(tmp, p)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _refresh_in_background() -> None:
|
|
130
|
+
"""Best effort, and deliberately fire-and-forget: nothing waits for this, including the caller."""
|
|
131
|
+
try:
|
|
132
|
+
# Touch the cache first so a burst of commands does not each spawn a refresher.
|
|
133
|
+
_write_cache(_read_cache().get("latest") or "")
|
|
134
|
+
|
|
135
|
+
code = _REFRESH_SCRIPT % {
|
|
136
|
+
"url": PYPI_URL,
|
|
137
|
+
"timeout": NETWORK_TIMEOUT_S,
|
|
138
|
+
"cache": str(_cache_path()),
|
|
139
|
+
}
|
|
140
|
+
devnull = subprocess.DEVNULL
|
|
141
|
+
subprocess.Popen(
|
|
142
|
+
[sys.executable, "-c", code],
|
|
143
|
+
stdin=devnull,
|
|
144
|
+
stdout=devnull,
|
|
145
|
+
stderr=devnull,
|
|
146
|
+
start_new_session=True, # survives the CLI exiting
|
|
147
|
+
)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def disabled() -> bool:
|
|
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
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def notice() -> Optional[str]:
|
|
164
|
+
"""The line to show, or None. Reads the cache only; never touches the network on this path."""
|
|
165
|
+
if disabled():
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
installed = _installed_version()
|
|
169
|
+
if not installed:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
cache = _read_cache()
|
|
173
|
+
if time.time() - float(cache.get("checked_at") or 0) > CHECK_INTERVAL_S:
|
|
174
|
+
_refresh_in_background() # for next time; this run uses whatever is already cached
|
|
175
|
+
|
|
176
|
+
latest = cache.get("latest")
|
|
177
|
+
if not latest or not is_newer(latest, installed):
|
|
178
|
+
return None
|
|
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
|
+
|
|
185
|
+
return f"levers9 {latest} is available (you have {installed}) · uv tool upgrade levers9"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def print_notice() -> None:
|
|
189
|
+
"""Best-effort, and silent about its own failures."""
|
|
190
|
+
try:
|
|
191
|
+
line = notice()
|
|
192
|
+
if not line:
|
|
193
|
+
return
|
|
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()
|
|
197
|
+
except Exception:
|
|
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()}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: levers9
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: levers9 SDK and CLI: run
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: levers9 SDK and CLI: run jobs, endpoints, queues, crons, boxes and GPU work on a levers9 cluster
|
|
5
5
|
Author-email: Levers AI Lab <hello@uselevers.com>
|
|
6
6
|
Requires-Python: <4.0,>=3.8
|
|
7
7
|
Requires-Dist: typeguard<3.0.0,>=2.13.3; python_version < "3.9"
|
|
@@ -76,6 +76,7 @@ src/levers9/cli/shell.py
|
|
|
76
76
|
src/levers9/cli/skills.py
|
|
77
77
|
src/levers9/cli/task.py
|
|
78
78
|
src/levers9/cli/token.py
|
|
79
|
+
src/levers9/cli/update_check.py
|
|
79
80
|
src/levers9/cli/volume.py
|
|
80
81
|
src/levers9/cli/worker.py
|
|
81
82
|
src/levers9/cli/worker_management.py
|
|
@@ -139,6 +140,8 @@ src/levers9/vendor/pathspec/patterns/__init__.py
|
|
|
139
140
|
src/levers9/vendor/pathspec/patterns/gitwildmatch.py
|
|
140
141
|
tests/test_capacity.py
|
|
141
142
|
tests/test_cli.py
|
|
143
|
+
tests/test_client_base_url.py
|
|
144
|
+
tests/test_config.py
|
|
142
145
|
tests/test_container_runner.py
|
|
143
146
|
tests/test_deployment.py
|
|
144
147
|
tests/test_env.py
|
|
@@ -160,5 +163,6 @@ tests/test_service.py
|
|
|
160
163
|
tests/test_shell.py
|
|
161
164
|
tests/test_skills.py
|
|
162
165
|
tests/test_task_queue.py
|
|
166
|
+
tests/test_update_check.py
|
|
163
167
|
tests/test_utils.py
|
|
164
168
|
tests/test_volumes.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"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The behaviours that matter: never blocks, never fails a command, never nags wrongly."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from levers9.cli import update_check as uc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VersionCompare(unittest.TestCase):
|
|
14
|
+
def test_newer_is_detected(self):
|
|
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
|
+
]:
|
|
21
|
+
self.assertTrue(uc.is_newer(latest, installed), f"{latest} > {installed}")
|
|
22
|
+
|
|
23
|
+
def test_same_or_older_is_not(self):
|
|
24
|
+
for latest, installed in [("0.2.0", "0.2.0"), ("0.1.9", "0.2.0"), ("0.9.0", "0.10.0")]:
|
|
25
|
+
self.assertFalse(uc.is_newer(latest, installed), f"{latest} !> {installed}")
|
|
26
|
+
|
|
27
|
+
def test_prerelease_sorts_below_its_final(self):
|
|
28
|
+
self.assertFalse(uc.is_newer("0.2.0rc1", "0.2.0"))
|
|
29
|
+
self.assertTrue(uc.is_newer("0.2.0", "0.2.0rc1"))
|
|
30
|
+
|
|
31
|
+
def test_garbage_never_raises(self):
|
|
32
|
+
self.assertFalse(uc.is_newer("not-a-version", "0.1.0"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Notice(unittest.TestCase):
|
|
36
|
+
def setUp(self):
|
|
37
|
+
self.tmp = Path(os.environ.get("TMPDIR", "/tmp")) / f"uc-{time.time_ns()}"
|
|
38
|
+
self.tmp.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self.patches = [
|
|
40
|
+
mock.patch.object(uc, "_cache_path", lambda: self.tmp / "update-check.json"),
|
|
41
|
+
mock.patch.object(uc, "_installed_version", lambda: "0.1.272"),
|
|
42
|
+
mock.patch.dict(os.environ, {"LEVERS9_NO_UPDATE_CHECK": "", "CI": ""}, clear=False),
|
|
43
|
+
mock.patch("sys.stderr.isatty", lambda: True),
|
|
44
|
+
]
|
|
45
|
+
for patch in self.patches:
|
|
46
|
+
patch.start()
|
|
47
|
+
|
|
48
|
+
def tearDown(self):
|
|
49
|
+
for patch in self.patches:
|
|
50
|
+
patch.stop()
|
|
51
|
+
|
|
52
|
+
def _cache(self, latest, age_s=0):
|
|
53
|
+
(self.tmp / "update-check.json").write_text(
|
|
54
|
+
json.dumps({"latest": latest, "checked_at": time.time() - age_s})
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_fresh_cache_with_newer_version_notifies(self):
|
|
58
|
+
self._cache("0.2.0")
|
|
59
|
+
n = uc.notice()
|
|
60
|
+
self.assertIn("0.2.0", n)
|
|
61
|
+
self.assertIn("0.1.272", n)
|
|
62
|
+
self.assertIn("upgrade", n)
|
|
63
|
+
|
|
64
|
+
def test_same_version_is_silent(self):
|
|
65
|
+
self._cache("0.1.272")
|
|
66
|
+
self.assertIsNone(uc.notice())
|
|
67
|
+
|
|
68
|
+
def test_no_cache_is_silent_and_does_not_block(self):
|
|
69
|
+
started = time.monotonic()
|
|
70
|
+
with mock.patch.object(uc, "_refresh_in_background") as bg:
|
|
71
|
+
self.assertIsNone(uc.notice())
|
|
72
|
+
bg.assert_called_once() # refresh is scheduled for next time
|
|
73
|
+
self.assertLess(time.monotonic() - started, 0.1, "notice() must not wait on anything")
|
|
74
|
+
|
|
75
|
+
def test_stale_cache_still_uses_what_it_has(self):
|
|
76
|
+
self._cache("0.2.0", age_s=uc.CHECK_INTERVAL_S + 10)
|
|
77
|
+
with mock.patch.object(uc, "_refresh_in_background") as bg:
|
|
78
|
+
self.assertIn("0.2.0", uc.notice())
|
|
79
|
+
bg.assert_called_once()
|
|
80
|
+
|
|
81
|
+
def test_opt_out_and_ci_are_silent(self):
|
|
82
|
+
self._cache("0.2.0")
|
|
83
|
+
for var in ("LEVERS9_NO_UPDATE_CHECK", "CI"):
|
|
84
|
+
with mock.patch.dict(os.environ, {var: "1"}):
|
|
85
|
+
self.assertIsNone(uc.notice(), f"{var} should silence it")
|
|
86
|
+
|
|
87
|
+
def test_corrupt_cache_is_survivable(self):
|
|
88
|
+
(self.tmp / "update-check.json").write_text("{not json")
|
|
89
|
+
with mock.patch.object(uc, "_refresh_in_background"):
|
|
90
|
+
self.assertIsNone(uc.notice())
|
|
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
|
+
|
|
114
|
+
def test_print_notice_never_raises(self):
|
|
115
|
+
with mock.patch.object(uc, "notice", side_effect=RuntimeError("boom")):
|
|
116
|
+
uc.print_notice() # must swallow: a notice is never worth failing a command
|
|
117
|
+
|
|
118
|
+
def test_unreachable_pypi_is_silent(self):
|
|
119
|
+
with mock.patch("urllib.request.urlopen", side_effect=OSError("no network")):
|
|
120
|
+
self.assertIsNone(uc._fetch_latest())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main(verbosity=2)
|
|
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
|