talkops-opscloud 0.3.0b1__py3-none-any.whl

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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
opscloud/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """OpsCloud — Autonomous CLI-first AI agent for AWS Infrastructure and Applications."""
2
+
3
+ from opscloud._version import __version__
4
+
5
+ __all__ = ["__version__"]
opscloud/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m opscloud."""
2
+
3
+ from opscloud.cli.main import cli_main
4
+
5
+ if __name__ == "__main__":
6
+ cli_main()
opscloud/_constants.py ADDED
@@ -0,0 +1,53 @@
1
+ """Global constant definitions for opscloud."""
2
+
3
+ from pathlib import Path
4
+
5
+ DEFAULT_AGENT_NAME = "opscloud"
6
+ DEFAULT_ASSISTANT_ID = "opscloud"
7
+
8
+ # App directories
9
+ USER_HOME = Path.home()
10
+ OPSCLOUD_HOME = USER_HOME / ".opscloud"
11
+ STATE_DIR = OPSCLOUD_HOME / ".state"
12
+ SESSIONS_DB_NAME = "sessions.db"
13
+ SESSIONS_DB_PATH = STATE_DIR / SESSIONS_DB_NAME
14
+ SERVER_RUNTIME_DIR = STATE_DIR / "server"
15
+ CONFIG_FILE_PATH = OPSCLOUD_HOME / "config.toml"
16
+ PLUGINS_CACHE_DIR = OPSCLOUD_HOME / "plugins" / "cache"
17
+
18
+ # Environment variables prefixes
19
+ ENV_PREFIX = "OPSCLOUD_"
20
+ SERVER_ENV_PREFIX = "OPSCLOUD_SERVER_"
21
+
22
+ # Tooling and shell execution
23
+ SHELL_TIMEOUT_SECONDS = 60
24
+ DEFAULT_PORT = 0 # Ephemeral
25
+
26
+ # Large tool results prefix (used by rubrics and compaction)
27
+ LARGE_TOOL_RESULTS_PREFIX = "/large_tool_results/"
28
+ SYSTEM_MESSAGE_PREFIX = "[System Message]"
29
+
30
+ FS_TOOL_NAMES = frozenset(
31
+ {"ls", "read_file", "write_file", "edit_file", "delete", "glob", "grep", "execute", "run_command"}
32
+ )
33
+
34
+ READONLY_FS_TOOLS = frozenset(
35
+ {
36
+ "ls",
37
+ "read_file",
38
+ "glob",
39
+ "grep",
40
+ "view_file",
41
+ "list_dir",
42
+ "dir_list",
43
+ "grep_search",
44
+ "file_search",
45
+ "read_url_content",
46
+ "fetch_web_page",
47
+ "search_web",
48
+ "get_goal",
49
+ "get_rubric",
50
+ "update_goal",
51
+ "write_todos",
52
+ }
53
+ )
opscloud/_debug.py ADDED
@@ -0,0 +1,145 @@
1
+ """Shared debug-logging configuration for verbose file-based tracing.
2
+
3
+ When the `OPSCODE_DEBUG` environment variable is set, modules that handle
4
+ streaming or remote communication can enable detailed file-based logging. This
5
+ helper centralizes the setup so the env-var name, file path, and format are
6
+ defined in one place.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from opscloud.config.env_vars import (
17
+ DEBUG,
18
+ DEBUG_FILE,
19
+ DEFAULT_DEBUG_FILE,
20
+ LOG_LEVEL,
21
+ is_env_truthy,
22
+ )
23
+ from opscloud.utils.logger import get_logger
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ _DEBUG_HANDLER_ATTR = "_opscloud_debug_handler"
28
+ LOG_LEVELS = {
29
+ "DEBUG": logging.DEBUG,
30
+ "INFO": logging.INFO,
31
+ "WARNING": logging.WARNING,
32
+ "ERROR": logging.ERROR,
33
+ "CRITICAL": logging.CRITICAL,
34
+ }
35
+ """Canonical level-name to `logging` level mapping.
36
+
37
+ The single source of truth for level names and their numeric values, shared with
38
+ the Debug Console's level filter so severity ordering is never re-derived from
39
+ hardcoded integers.
40
+ """
41
+
42
+
43
+ def resolve_log_level(*, debug_enabled: bool | None = None) -> int:
44
+ """Resolve the configured runtime logging level.
45
+
46
+ Args:
47
+ debug_enabled: Whether `OPSCODE_DEBUG` is truthy. When omitted,
48
+ the current environment is checked.
49
+
50
+ Returns:
51
+ A standard `logging` level integer. Defaults to `DEBUG` when debug file
52
+ logging is enabled and `INFO` otherwise.
53
+ """
54
+ if debug_enabled is None:
55
+ debug_enabled = is_env_truthy(DEBUG)
56
+ fallback = logging.DEBUG if debug_enabled else logging.INFO
57
+ raw = os.environ.get(LOG_LEVEL)
58
+ if raw is None or not raw.strip():
59
+ return fallback
60
+ level = LOG_LEVELS.get(raw.strip().upper())
61
+ if level is not None:
62
+ return level
63
+ valid = ", ".join(LOG_LEVELS)
64
+ message = f"ignoring invalid {LOG_LEVEL}={raw!r}; expected one of {valid}"
65
+ # stderr for headless / pre-TUI visibility; the logger so it also lands in
66
+ # the always-on in-memory buffer and surfaces in the Debug Console.
67
+ print(f"Warning: {message}", file=sys.stderr) # noqa: T201
68
+ logger.warning("%s", message)
69
+ return fallback
70
+
71
+
72
+ def configure_debug_logging(target: logging.Logger) -> None:
73
+ """Attach a file handler to *target* when `OPSCODE_DEBUG` is set.
74
+
75
+ Intended to be called once on the `opscloud` package logger; child
76
+ module loggers reach the same file via propagation, so individual modules do
77
+ not configure logging themselves.
78
+
79
+ The log file defaults to `DEFAULT_DEBUG_FILE` but can be overridden with
80
+ `OPSCODE_DEBUG_FILE`. The handler appends (`mode='a'`) so logs
81
+ are preserved across separate process runs. Calling this again with the same
82
+ resolved path is a no-op: the existing tagged handler is reused rather than
83
+ stacking duplicates. If the resolved path changes, the stale handler is
84
+ closed and replaced.
85
+
86
+ Does nothing when `OPSCODE_DEBUG` is not truthy (see `is_env_truthy`).
87
+
88
+ Args:
89
+ target: Logger to configure.
90
+ """
91
+ debug_enabled = is_env_truthy(DEBUG)
92
+ level = resolve_log_level(debug_enabled=debug_enabled)
93
+ target.setLevel(level)
94
+
95
+ if not debug_enabled:
96
+ return
97
+
98
+ debug_path = Path(os.environ.get(DEBUG_FILE, DEFAULT_DEBUG_FILE))
99
+ for existing in list(target.handlers):
100
+ if not (
101
+ isinstance(existing, logging.FileHandler)
102
+ and getattr(existing, _DEBUG_HANDLER_ATTR, False)
103
+ ):
104
+ continue
105
+ if Path(existing.baseFilename) == debug_path:
106
+ # Already configured for this path; reuse rather than duplicate.
107
+ existing.setLevel(level)
108
+ return
109
+ # The debug path changed; drop the stale handler before re-attaching so
110
+ # we don't leak its file descriptor or fan logs out to two files.
111
+ target.removeHandler(existing)
112
+ existing.close()
113
+
114
+ try:
115
+ handler = logging.FileHandler(str(debug_path), mode="a")
116
+ except OSError as exc:
117
+ print( # noqa: T201
118
+ f"Warning: could not open debug log file {debug_path}: {exc}",
119
+ file=sys.stderr,
120
+ )
121
+ return
122
+ setattr(handler, _DEBUG_HANDLER_ATTR, True)
123
+ handler.setLevel(level)
124
+ handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(message)s"))
125
+ target.addHandler(handler)
126
+
127
+
128
+ def installed_debug_log_path() -> Path | None:
129
+ """Return the path of the active debug log file, or `None` if not logging.
130
+
131
+ Reflects the file handler actually attached by `configure_debug_logging`,
132
+ not the current `OPSCODE_DEBUG` env value. The two diverge when the
133
+ variable is set after import — e.g. via a project/global `.env` loaded during
134
+ settings bootstrap — in which case the variable reads truthy but no handler
135
+ was installed and no log file exists. Callers that surface "full error in
136
+ <path>" hints must use this rather than the env var to avoid pointing users
137
+ at a file that was never created.
138
+ """
139
+ package_logger = logging.getLogger(__package__ or "opscloud")
140
+ for handler in package_logger.handlers:
141
+ if isinstance(handler, logging.FileHandler) and getattr(
142
+ handler, _DEBUG_HANDLER_ATTR, False
143
+ ):
144
+ return Path(handler.baseFilename)
145
+ return None
@@ -0,0 +1,250 @@
1
+ r"""In-memory ring buffer of recent log records for the in-app Debug Console.
2
+
3
+ A lightweight `logging.Handler` keeps the most recent structured log records in
4
+ bounded per-level `deque`s so the Debug Console (`Ctrl+\`) can show a live tail
5
+ without requiring the opt-in file logging from `_debug.configure_debug_logging`.
6
+ Partitioning retention by level means a burst of `DEBUG` output cannot evict the
7
+ rarer `INFO`/`WARNING`/`ERROR` records the level filter needs. The handler is
8
+ installed once on the `opscloud` package logger (see `__init__.py`); child
9
+ module loggers reach it via propagation.
10
+
11
+ Installation is negligible, and each emitted record only appends a structured
12
+ record to a bounded `deque`, so the buffer is cheap enough to keep always on.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import operator
19
+ import os
20
+ from collections import deque
21
+ from dataclasses import dataclass
22
+
23
+ from opscloud._debug import LOG_LEVELS
24
+ from opscloud.config.env_vars import LOG_LEVEL
25
+
26
+ DEFAULT_CAPACITY = 1000
27
+ """Maximum number of records retained per level in the ring buffer."""
28
+
29
+ _FALLBACK_LEVEL_BUCKET = "_other"
30
+ """Retention bucket for records whose level name is not in `LOG_LEVELS`.
31
+
32
+ Non-standard level names are retained here rather than discarded at
33
+ classification time, and sharing one bucket keeps the number of per-level deques
34
+ bounded no matter how many distinct custom names appear. Like every bucket, this
35
+ one is bounded to *capacity* and evicts oldest-first, so — unlike the standard
36
+ levels, which each get isolated retention — distinct custom levels compete for a
37
+ single budget and can evict one another."""
38
+
39
+ _DATE_FORMAT = "%H:%M:%S"
40
+ _FORMATTER = logging.Formatter(datefmt=_DATE_FORMAT)
41
+
42
+
43
+ def retention_bucket_for_level(level: str) -> str:
44
+ """Return the bounded retention bucket key for a log level name."""
45
+ return level if level in LOG_LEVELS else _FALLBACK_LEVEL_BUCKET
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class InMemoryLogRecord:
50
+ """Structured log record retained by the in-memory debug buffer."""
51
+
52
+ timestamp: str
53
+ level: str
54
+ levelno: int
55
+ logger: str
56
+ message: str
57
+
58
+ @property
59
+ def plain_line(self) -> str:
60
+ """The record in the legacy plain-text debug-console format."""
61
+ return f"{self.timestamp} {self.level} {self.logger} {self.message}"
62
+
63
+
64
+ class InMemoryLogBuffer(logging.Handler):
65
+ """Logging handler retaining the most recent structured records in memory.
66
+
67
+ Retention is partitioned by level: each recognized level name (see
68
+ `LOG_LEVELS`) keeps its own bounded `deque` of *capacity* records, while
69
+ unrecognized names share the `_FALLBACK_LEVEL_BUCKET` deque. A burst of
70
+ high-volume records at one recognized level (typically `DEBUG`) therefore
71
+ cannot evict the rarer, higher-severity records at other recognized levels,
72
+ so the Debug Console's level filter can still surface them. Each retained
73
+ record is tagged with its monotonic emission sequence number so snapshots
74
+ stay chronologically ordered across levels.
75
+ """
76
+
77
+ def __init__(self, capacity: int = DEFAULT_CAPACITY) -> None:
78
+ """Create the handler with a bounded backing `deque` per level.
79
+
80
+ Args:
81
+ capacity: Maximum records to retain per level; oldest are dropped
82
+ first.
83
+
84
+ Raises:
85
+ ValueError: If *capacity* is less than 1. A zero-capacity buffer
86
+ would silently discard every record while `total_emitted` kept
87
+ climbing, so it is rejected at the boundary.
88
+ """
89
+ if capacity < 1:
90
+ msg = f"capacity must be >= 1, got {capacity}"
91
+ raise ValueError(msg)
92
+ super().__init__()
93
+ self._capacity = capacity
94
+ self._levels: dict[str, deque[tuple[int, InMemoryLogRecord]]] = {}
95
+ self._total = 0
96
+
97
+ def emit(self, record: logging.LogRecord) -> None:
98
+ """Append the structured *record* to its level's ring buffer."""
99
+ self.acquire()
100
+ try:
101
+ structured = self._make_record(record)
102
+ bucket = self._level_bucket(structured.level)
103
+ bucket.append((self._total, structured))
104
+ self._total += 1
105
+ except Exception: # noqa: BLE001 # never let logging crash the app
106
+ self.handleError(record)
107
+ finally:
108
+ self.release()
109
+
110
+ def _level_bucket(self, level: str) -> deque[tuple[int, InMemoryLogRecord]]:
111
+ """Return the retention deque for *level*, creating it on first use."""
112
+ key = retention_bucket_for_level(level)
113
+ bucket = self._levels.get(key)
114
+ if bucket is None:
115
+ bucket = deque(maxlen=self._capacity)
116
+ self._levels[key] = bucket
117
+ return bucket
118
+
119
+ @property
120
+ def total_emitted(self) -> int:
121
+ """Total records ever emitted (monotonic; survives buffer eviction)."""
122
+ self.acquire()
123
+ try:
124
+ return self._total
125
+ finally:
126
+ self.release()
127
+
128
+ def snapshot_records_since(self, index: int) -> tuple[list[InMemoryLogRecord], int]:
129
+ """Return retained structured records and the next absolute index.
130
+
131
+ Absolute indices are stable even as old records are evicted, so callers
132
+ can poll incrementally without re-reading records they already consumed.
133
+ Returning the records and the resume index together under the handler
134
+ lock prevents a concurrent append from being skipped between separate
135
+ reads.
136
+
137
+ Args:
138
+ index: Absolute emission index to start from.
139
+
140
+ Returns:
141
+ A tuple of the structured records from *index* onward that are still
142
+ retained, and the current total emitted count to resume from.
143
+ """
144
+ self.acquire()
145
+ try:
146
+ return self._records_since_unlocked(index), self._total
147
+ finally:
148
+ self.release()
149
+
150
+ def _records_since_unlocked(self, index: int) -> list[InMemoryLogRecord]:
151
+ """Return retained records from *index* while the handler lock is held.
152
+
153
+ Merges the per-level deques and restores chronological order using each
154
+ record's emission sequence number, so callers see one ordered tail even
155
+ though retention is partitioned by level.
156
+ """
157
+ tagged = [
158
+ entry
159
+ for bucket in self._levels.values()
160
+ for entry in bucket
161
+ if entry[0] >= index
162
+ ]
163
+ tagged.sort(key=operator.itemgetter(0))
164
+ return [record for _seq, record in tagged]
165
+
166
+ @staticmethod
167
+ def _make_record(record: logging.LogRecord) -> InMemoryLogRecord:
168
+ """Convert a standard logging record into a structured debug record.
169
+
170
+ Returns:
171
+ Structured record for display and filtering.
172
+ """
173
+ message = record.getMessage()
174
+ if record.exc_info:
175
+ if not record.exc_text:
176
+ record.exc_text = _FORMATTER.formatException(record.exc_info)
177
+ if record.exc_text:
178
+ message = f"{message}\n{record.exc_text}"
179
+ if record.stack_info:
180
+ message = f"{message}\n{_FORMATTER.formatStack(record.stack_info)}"
181
+ return InMemoryLogRecord(
182
+ timestamp=_FORMATTER.formatTime(record, _DATE_FORMAT),
183
+ level=record.levelname,
184
+ levelno=record.levelno,
185
+ logger=record.name,
186
+ message=message,
187
+ )
188
+
189
+
190
+ _buffer: InMemoryLogBuffer | None = None
191
+
192
+
193
+ def install_log_buffer(
194
+ target: logging.Logger, capacity: int = DEFAULT_CAPACITY
195
+ ) -> InMemoryLogBuffer:
196
+ """Attach the in-memory buffer handler to *target* (idempotent).
197
+
198
+ Lowers *target*'s level to at most `INFO` so the console shows a useful tail
199
+ even when `OPSCODE_DEBUG` is off; never raises the level. In
200
+ `__init__.py` this runs *before* `configure_debug_logging`, which then sets
201
+ the final level (honoring `OPSCODE_DEBUG` and
202
+ `OPSCODE_LOG_LEVEL`) over this `INFO` floor — so any startup warnings
203
+ `configure_debug_logging` emits are captured by the already-installed buffer.
204
+ On a fresh `NOTSET` logger the `NOTSET` branch forces `INFO` without
205
+ consulting `OPSCODE_LOG_LEVEL`; the `> INFO and no env` branch only
206
+ matters on reconfiguration (e.g. an `importlib.reload`), where it preserves
207
+ an explicit `OPSCODE_LOG_LEVEL` rather than clobbering it with `INFO`.
208
+
209
+ Lowering the level does not spill log output onto the terminal: because this
210
+ handler is present in the propagation chain, `Logger.callHandlers` finds a
211
+ handler (`found > 0`) and Python's `lastResort` stderr handler is never
212
+ consulted. The exception is an embedding process that attaches its own
213
+ `INFO`-or-lower handler to the root logger, which would then see the
214
+ propagated records.
215
+
216
+ Note: this runs as an import-time side effect (see `__init__.py`), so every
217
+ `import opscloud` attaches the handler and may lower the package
218
+ logger's level to `INFO` for the lifetime of the process.
219
+
220
+ Args:
221
+ target: Logger to attach the buffer to (the package logger).
222
+ capacity: Maximum records to retain.
223
+
224
+ Returns:
225
+ The installed (or already-installed) buffer handler.
226
+ """
227
+ global _buffer # noqa: PLW0603 # module-level singleton accessor
228
+ for existing in target.handlers:
229
+ if isinstance(existing, InMemoryLogBuffer):
230
+ _buffer = existing
231
+ return existing
232
+
233
+ handler = InMemoryLogBuffer(capacity)
234
+ handler.setLevel(logging.DEBUG)
235
+ target.addHandler(handler)
236
+ if target.level == logging.NOTSET or (
237
+ target.level > logging.INFO and not os.environ.get(LOG_LEVEL)
238
+ ):
239
+ target.setLevel(logging.INFO)
240
+ _buffer = handler
241
+ return handler
242
+
243
+
244
+ def get_log_buffer() -> InMemoryLogBuffer | None:
245
+ """Return the installed buffer handler.
246
+
247
+ Returns:
248
+ The installed buffer handler, or `None` if not yet installed.
249
+ """
250
+ return _buffer