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.
Files changed (170) hide show
  1. {levers9-0.2.1 → levers9-0.2.2}/PKG-INFO +1 -1
  2. {levers9-0.2.1 → levers9-0.2.2}/pyproject.toml +1 -1
  3. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/update_check.py +30 -6
  4. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/client.py +17 -3
  5. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/config.py +11 -1
  6. {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/PKG-INFO +1 -1
  7. {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/SOURCES.txt +2 -0
  8. levers9-0.2.2/tests/test_client_base_url.py +38 -0
  9. levers9-0.2.2/tests/test_config.py +60 -0
  10. {levers9-0.2.1 → levers9-0.2.2}/tests/test_update_check.py +47 -9
  11. {levers9-0.2.1 → levers9-0.2.2}/README.md +0 -0
  12. {levers9-0.2.1 → levers9-0.2.2}/setup.cfg +0 -0
  13. {levers9-0.2.1 → levers9-0.2.2}/src/__init__.py +0 -0
  14. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/__init__.py +0 -0
  15. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/__main__.py +0 -0
  16. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/__init__.py +0 -0
  17. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/__init__.py +0 -0
  18. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/capacity.py +0 -0
  19. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/container.py +0 -0
  20. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/runner.py +0 -0
  21. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/base/utils.py +0 -0
  22. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/blueprint.py +0 -0
  23. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/box.py +0 -0
  24. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/endpoint.py +0 -0
  25. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/__init__.py +0 -0
  26. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/__init__.py +0 -0
  27. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/bot.py +0 -0
  28. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/marker.py +0 -0
  29. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/bot/types.py +0 -0
  30. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/experimental/signal.py +0 -0
  31. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/integrations/__init__.py +0 -0
  32. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/integrations/fastmcp.py +0 -0
  33. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/integrations/vllm.py +0 -0
  34. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/job.py +0 -0
  35. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/map.py +0 -0
  36. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/mixins.py +0 -0
  37. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/output.py +0 -0
  38. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/queue.py +0 -0
  39. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/sandbox.py +0 -0
  40. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/service.py +0 -0
  41. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/shell.py +0 -0
  42. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/abstractions/volume.py +0 -0
  43. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/agent/__init__.py +0 -0
  44. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/agent/server.py +0 -0
  45. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/agent/tools.py +0 -0
  46. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/aio.py +0 -0
  47. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/channel.py +0 -0
  48. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/__init__.py +0 -0
  49. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/config.py +0 -0
  50. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/container.py +0 -0
  51. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/database.py +0 -0
  52. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/deployment.py +0 -0
  53. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/dev.py +0 -0
  54. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/disk.py +0 -0
  55. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/extraclick.py +0 -0
  56. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/llm.py +0 -0
  57. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/login.py +0 -0
  58. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/machine.py +0 -0
  59. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/machine_format.py +0 -0
  60. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/main.py +0 -0
  61. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/mcp.py +0 -0
  62. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/pool.py +0 -0
  63. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/run.py +0 -0
  64. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/secret.py +0 -0
  65. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/serve.py +0 -0
  66. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/shell.py +0 -0
  67. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/skills.py +0 -0
  68. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/task.py +0 -0
  69. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/token.py +0 -0
  70. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/volume.py +0 -0
  71. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/worker.py +0 -0
  72. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/cli/worker_management.py +0 -0
  73. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/__init__.py +0 -0
  74. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/deployment.py +0 -0
  75. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/client/task.py +0 -0
  76. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/__init__.py +0 -0
  77. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/blueprint/__init__.py +0 -0
  78. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/bot/__init__.py +0 -0
  79. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/box/__init__.py +0 -0
  80. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/controlplane/__init__.py +0 -0
  81. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/disk/__init__.py +0 -0
  82. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/endpoint/__init__.py +0 -0
  83. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/google/__init__.py +0 -0
  84. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/google/api/__init__.py +0 -0
  85. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/job/__init__.py +0 -0
  86. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/map/__init__.py +0 -0
  87. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/output/__init__.py +0 -0
  88. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/queue/__init__.py +0 -0
  89. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/secret/__init__.py +0 -0
  90. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/shell/__init__.py +0 -0
  91. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/signal/__init__.py +0 -0
  92. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/types/__init__.py +0 -0
  93. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/clients/volume/__init__.py +0 -0
  94. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/env.py +0 -0
  95. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/exceptions.py +0 -0
  96. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/integrations.py +0 -0
  97. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/logging.py +0 -0
  98. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/middleware.py +0 -0
  99. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/multipart.py +0 -0
  100. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/__init__.py +0 -0
  101. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/bot/__init__.py +0 -0
  102. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/bot/transition.py +0 -0
  103. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/common.py +0 -0
  104. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/container.py +0 -0
  105. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/endpoint.py +0 -0
  106. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/job.py +0 -0
  107. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/queue.py +0 -0
  108. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/runner/serve.py +0 -0
  109. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/schema.py +0 -0
  110. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/README.md +0 -0
  111. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/__init__.py +0 -0
  112. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-blueprints/SKILL.md +0 -0
  113. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-boxes/SKILL.md +0 -0
  114. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-cli/SKILL.md +0 -0
  115. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-crons/SKILL.md +0 -0
  116. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-dashboard/SKILL.md +0 -0
  117. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-data-lab/SKILL.md +0 -0
  118. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-endpoints/SKILL.md +0 -0
  119. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-gpu/SKILL.md +0 -0
  120. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-jobs/SKILL.md +0 -0
  121. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-llm-serving/SKILL.md +0 -0
  122. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-mcp-server/SKILL.md +0 -0
  123. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-pipelines/SKILL.md +0 -0
  124. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-queues/SKILL.md +0 -0
  125. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-rl-arena/SKILL.md +0 -0
  126. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-sandboxes/SKILL.md +0 -0
  127. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/skills/levers9-volumes-secrets/SKILL.md +0 -0
  128. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/sync.py +0 -0
  129. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/terminal.py +0 -0
  130. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/type.py +0 -0
  131. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/utils.py +0 -0
  132. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/__init__.py +0 -0
  133. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/_meta.py +0 -0
  134. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/gitignore.py +0 -0
  135. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/pathspec.py +0 -0
  136. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/pattern.py +0 -0
  137. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/__init__.py +0 -0
  138. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/patterns/gitwildmatch.py +0 -0
  139. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/py.typed +0 -0
  140. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/vendor/pathspec/util.py +0 -0
  141. {levers9-0.2.1 → levers9-0.2.2}/src/levers9/whereami.py +0 -0
  142. {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/dependency_links.txt +0 -0
  143. {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/entry_points.txt +0 -0
  144. {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/requires.txt +0 -0
  145. {levers9-0.2.1 → levers9-0.2.2}/src/levers9.egg-info/top_level.txt +0 -0
  146. {levers9-0.2.1 → levers9-0.2.2}/tests/test_capacity.py +0 -0
  147. {levers9-0.2.1 → levers9-0.2.2}/tests/test_cli.py +0 -0
  148. {levers9-0.2.1 → levers9-0.2.2}/tests/test_container_runner.py +0 -0
  149. {levers9-0.2.1 → levers9-0.2.2}/tests/test_deployment.py +0 -0
  150. {levers9-0.2.1 → levers9-0.2.2}/tests/test_env.py +0 -0
  151. {levers9-0.2.1 → levers9-0.2.2}/tests/test_filesyncer.py +0 -0
  152. {levers9-0.2.1 → levers9-0.2.2}/tests/test_function.py +0 -0
  153. {levers9-0.2.1 → levers9-0.2.2}/tests/test_function_runner.py +0 -0
  154. {levers9-0.2.1 → levers9-0.2.2}/tests/test_image.py +0 -0
  155. {levers9-0.2.1 → levers9-0.2.2}/tests/test_machine_cli.py +0 -0
  156. {levers9-0.2.1 → levers9-0.2.2}/tests/test_map.py +0 -0
  157. {levers9-0.2.1 → levers9-0.2.2}/tests/test_multipart.py +0 -0
  158. {levers9-0.2.1 → levers9-0.2.2}/tests/test_pod_instance.py +0 -0
  159. {levers9-0.2.1 → levers9-0.2.2}/tests/test_pool.py +0 -0
  160. {levers9-0.2.1 → levers9-0.2.2}/tests/test_runner.py +0 -0
  161. {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_convenience.py +0 -0
  162. {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_docker_manager.py +0 -0
  163. {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_log_streaming_integration.py +0 -0
  164. {levers9-0.2.1 → levers9-0.2.2}/tests/test_sandbox_process_stream.py +0 -0
  165. {levers9-0.2.1 → levers9-0.2.2}/tests/test_service.py +0 -0
  166. {levers9-0.2.1 → levers9-0.2.2}/tests/test_shell.py +0 -0
  167. {levers9-0.2.1 → levers9-0.2.2}/tests/test_skills.py +0 -0
  168. {levers9-0.2.1 → levers9-0.2.2}/tests/test_task_queue.py +0 -0
  169. {levers9-0.2.1 → levers9-0.2.2}/tests/test_utils.py +0 -0
  170. {levers9-0.2.1 → levers9-0.2.2}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: levers9
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
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
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "pyyaml<7.0.0,>=6.0.0",
28
28
  ]
29
29
  name = "levers9"
30
- version = "0.2.1"
30
+ version = "0.2.2"
31
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]
@@ -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
- p.write_text(json.dumps({"latest": latest, "checked_at": time.time()}))
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
- return bool(os.getenv("LEVERS9_NO_UPDATE_CHECK") or os.getenv("CI"))
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
- from .. import terminal
171
-
172
- terminal.detail(line)
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 = 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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: levers9
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
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
@@ -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
- import json, os, sys, time, types, unittest
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 [("0.2.0", "0.1.272"), ("1.0.0", "0.9.9"), ("0.2.1", "0.2.0"), ("0.10.0", "0.9.0")]:
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["TMPDIR"] if "TMPDIR" in os.environ else "/tmp") / f"uc-{time.time_ns()}"
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 p in self.patches: p.start()
45
+ for patch in self.patches:
46
+ patch.start()
36
47
 
37
48
  def tearDown(self):
38
- for p in self.patches: p.stop()
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(json.dumps({"latest": latest, "checked_at": time.time() - age_s}))
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); self.assertIn("0.1.272", n); self.assertIn("upgrade", 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() # refresh is scheduled for next time
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() # must swallow: a notice is never worth failing a command
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