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.
Files changed (170) hide show
  1. {levers9-0.2.0 → levers9-0.2.2}/PKG-INFO +2 -2
  2. {levers9-0.2.0 → levers9-0.2.2}/pyproject.toml +2 -2
  3. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/main.py +9 -0
  4. levers9-0.2.2/src/levers9/cli/update_check.py +198 -0
  5. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/client.py +17 -3
  6. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/config.py +11 -1
  7. {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/PKG-INFO +2 -2
  8. {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/SOURCES.txt +4 -0
  9. levers9-0.2.2/tests/test_client_base_url.py +38 -0
  10. levers9-0.2.2/tests/test_config.py +60 -0
  11. levers9-0.2.2/tests/test_update_check.py +124 -0
  12. {levers9-0.2.0 → levers9-0.2.2}/README.md +0 -0
  13. {levers9-0.2.0 → levers9-0.2.2}/setup.cfg +0 -0
  14. {levers9-0.2.0 → levers9-0.2.2}/src/__init__.py +0 -0
  15. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/__init__.py +0 -0
  16. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/__main__.py +0 -0
  17. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/__init__.py +0 -0
  18. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/__init__.py +0 -0
  19. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/capacity.py +0 -0
  20. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/container.py +0 -0
  21. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/runner.py +0 -0
  22. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/base/utils.py +0 -0
  23. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/blueprint.py +0 -0
  24. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/box.py +0 -0
  25. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/endpoint.py +0 -0
  26. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/__init__.py +0 -0
  27. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/__init__.py +0 -0
  28. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/bot.py +0 -0
  29. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/marker.py +0 -0
  30. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/types.py +0 -0
  31. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/experimental/signal.py +0 -0
  32. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/integrations/__init__.py +0 -0
  33. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/integrations/fastmcp.py +0 -0
  34. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/integrations/vllm.py +0 -0
  35. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/job.py +0 -0
  36. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/map.py +0 -0
  37. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/mixins.py +0 -0
  38. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/output.py +0 -0
  39. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/queue.py +0 -0
  40. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/sandbox.py +0 -0
  41. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/service.py +0 -0
  42. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/shell.py +0 -0
  43. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/abstractions/volume.py +0 -0
  44. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/agent/__init__.py +0 -0
  45. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/agent/server.py +0 -0
  46. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/agent/tools.py +0 -0
  47. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/aio.py +0 -0
  48. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/channel.py +0 -0
  49. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/__init__.py +0 -0
  50. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/config.py +0 -0
  51. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/container.py +0 -0
  52. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/database.py +0 -0
  53. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/deployment.py +0 -0
  54. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/dev.py +0 -0
  55. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/disk.py +0 -0
  56. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/extraclick.py +0 -0
  57. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/llm.py +0 -0
  58. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/login.py +0 -0
  59. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/machine.py +0 -0
  60. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/machine_format.py +0 -0
  61. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/mcp.py +0 -0
  62. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/pool.py +0 -0
  63. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/run.py +0 -0
  64. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/secret.py +0 -0
  65. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/serve.py +0 -0
  66. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/shell.py +0 -0
  67. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/skills.py +0 -0
  68. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/task.py +0 -0
  69. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/token.py +0 -0
  70. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/volume.py +0 -0
  71. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/worker.py +0 -0
  72. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/cli/worker_management.py +0 -0
  73. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/__init__.py +0 -0
  74. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/deployment.py +0 -0
  75. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/client/task.py +0 -0
  76. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/__init__.py +0 -0
  77. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/blueprint/__init__.py +0 -0
  78. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/bot/__init__.py +0 -0
  79. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/box/__init__.py +0 -0
  80. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/controlplane/__init__.py +0 -0
  81. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/disk/__init__.py +0 -0
  82. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/endpoint/__init__.py +0 -0
  83. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/google/__init__.py +0 -0
  84. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/google/api/__init__.py +0 -0
  85. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/job/__init__.py +0 -0
  86. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/map/__init__.py +0 -0
  87. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/output/__init__.py +0 -0
  88. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/queue/__init__.py +0 -0
  89. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/secret/__init__.py +0 -0
  90. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/shell/__init__.py +0 -0
  91. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/signal/__init__.py +0 -0
  92. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/types/__init__.py +0 -0
  93. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/clients/volume/__init__.py +0 -0
  94. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/env.py +0 -0
  95. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/exceptions.py +0 -0
  96. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/integrations.py +0 -0
  97. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/logging.py +0 -0
  98. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/middleware.py +0 -0
  99. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/multipart.py +0 -0
  100. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/__init__.py +0 -0
  101. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/bot/__init__.py +0 -0
  102. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/bot/transition.py +0 -0
  103. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/common.py +0 -0
  104. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/container.py +0 -0
  105. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/endpoint.py +0 -0
  106. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/job.py +0 -0
  107. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/queue.py +0 -0
  108. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/runner/serve.py +0 -0
  109. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/schema.py +0 -0
  110. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/README.md +0 -0
  111. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/__init__.py +0 -0
  112. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-blueprints/SKILL.md +0 -0
  113. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-boxes/SKILL.md +0 -0
  114. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-cli/SKILL.md +0 -0
  115. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-crons/SKILL.md +0 -0
  116. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-dashboard/SKILL.md +0 -0
  117. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-data-lab/SKILL.md +0 -0
  118. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-endpoints/SKILL.md +0 -0
  119. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-gpu/SKILL.md +0 -0
  120. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-jobs/SKILL.md +0 -0
  121. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-llm-serving/SKILL.md +0 -0
  122. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-mcp-server/SKILL.md +0 -0
  123. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-pipelines/SKILL.md +0 -0
  124. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-queues/SKILL.md +0 -0
  125. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-rl-arena/SKILL.md +0 -0
  126. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-sandboxes/SKILL.md +0 -0
  127. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/skills/levers9-volumes-secrets/SKILL.md +0 -0
  128. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/sync.py +0 -0
  129. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/terminal.py +0 -0
  130. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/type.py +0 -0
  131. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/utils.py +0 -0
  132. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/__init__.py +0 -0
  133. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/_meta.py +0 -0
  134. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/gitignore.py +0 -0
  135. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/pathspec.py +0 -0
  136. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/pattern.py +0 -0
  137. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/__init__.py +0 -0
  138. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/gitwildmatch.py +0 -0
  139. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/py.typed +0 -0
  140. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/vendor/pathspec/util.py +0 -0
  141. {levers9-0.2.0 → levers9-0.2.2}/src/levers9/whereami.py +0 -0
  142. {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/dependency_links.txt +0 -0
  143. {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/entry_points.txt +0 -0
  144. {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/requires.txt +0 -0
  145. {levers9-0.2.0 → levers9-0.2.2}/src/levers9.egg-info/top_level.txt +0 -0
  146. {levers9-0.2.0 → levers9-0.2.2}/tests/test_capacity.py +0 -0
  147. {levers9-0.2.0 → levers9-0.2.2}/tests/test_cli.py +0 -0
  148. {levers9-0.2.0 → levers9-0.2.2}/tests/test_container_runner.py +0 -0
  149. {levers9-0.2.0 → levers9-0.2.2}/tests/test_deployment.py +0 -0
  150. {levers9-0.2.0 → levers9-0.2.2}/tests/test_env.py +0 -0
  151. {levers9-0.2.0 → levers9-0.2.2}/tests/test_filesyncer.py +0 -0
  152. {levers9-0.2.0 → levers9-0.2.2}/tests/test_function.py +0 -0
  153. {levers9-0.2.0 → levers9-0.2.2}/tests/test_function_runner.py +0 -0
  154. {levers9-0.2.0 → levers9-0.2.2}/tests/test_image.py +0 -0
  155. {levers9-0.2.0 → levers9-0.2.2}/tests/test_machine_cli.py +0 -0
  156. {levers9-0.2.0 → levers9-0.2.2}/tests/test_map.py +0 -0
  157. {levers9-0.2.0 → levers9-0.2.2}/tests/test_multipart.py +0 -0
  158. {levers9-0.2.0 → levers9-0.2.2}/tests/test_pod_instance.py +0 -0
  159. {levers9-0.2.0 → levers9-0.2.2}/tests/test_pool.py +0 -0
  160. {levers9-0.2.0 → levers9-0.2.2}/tests/test_runner.py +0 -0
  161. {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_convenience.py +0 -0
  162. {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_docker_manager.py +0 -0
  163. {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_log_streaming_integration.py +0 -0
  164. {levers9-0.2.0 → levers9-0.2.2}/tests/test_sandbox_process_stream.py +0 -0
  165. {levers9-0.2.0 → levers9-0.2.2}/tests/test_service.py +0 -0
  166. {levers9-0.2.0 → levers9-0.2.2}/tests/test_shell.py +0 -0
  167. {levers9-0.2.0 → levers9-0.2.2}/tests/test_skills.py +0 -0
  168. {levers9-0.2.0 → levers9-0.2.2}/tests/test_task_queue.py +0 -0
  169. {levers9-0.2.0 → levers9-0.2.2}/tests/test_utils.py +0 -0
  170. {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.0
4
- Summary: levers9 SDK and CLI: run functions, endpoints, task queues, schedules, pods and GPU jobs on a levers9 cluster
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.0"
31
- description = "levers9 SDK and CLI: run functions, endpoints, task queues, schedules, pods and GPU jobs on a levers9 cluster"
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 = settings.api_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
- self.controlplane_port = settings.api_port
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
- return cls(**{k: v for k, v in data.items() if k in inspect.signature(cls).parameters})
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.0
4
- Summary: levers9 SDK and CLI: run functions, endpoints, task queues, schedules, pods and GPU jobs on a levers9 cluster
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