codee-agent 0.1.0__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 (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
@@ -0,0 +1,176 @@
1
+ import json
2
+ import queue
3
+ import subprocess
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import Mock, patch
7
+
8
+ from codee_agent_github_copilot.provider import GitHubCopilotAgent, _await_result
9
+ from codee_main_context.context import Settings
10
+
11
+ SESSION = "82232f47-df60-4cb3-8c3a-de12074c9205"
12
+
13
+
14
+ def _event(kind: str, data: dict | None = None, **extra) -> str:
15
+ event = {"type": kind, **extra}
16
+ if data is not None:
17
+ event["data"] = data
18
+ return json.dumps(event)
19
+
20
+
21
+ def _stream(*lines: str) -> str:
22
+ return "\n".join(lines) + "\n"
23
+
24
+
25
+ def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
26
+ return subprocess.CompletedProcess(
27
+ args=["copilot"], returncode=returncode, stdout=stdout, stderr=stderr)
28
+
29
+
30
+ class CopilotRunTest(unittest.TestCase):
31
+ def setUp(self) -> None:
32
+ self.agent = GitHubCopilotAgent(Settings(), Path("/repo"))
33
+
34
+ def _run(self, completed, model: str = "") -> str:
35
+ with patch("subprocess.run", return_value=completed) as run:
36
+ self.captured = run
37
+ return self.agent.run("/do-it CORE-1", SESSION, model)
38
+
39
+ def test_returns_the_last_assistant_message(self) -> None:
40
+ stdout = _stream(
41
+ _event("assistant.message", {"content": "Looking at it", "toolRequests": [{}]}),
42
+ _event("tool.execution_complete", {}),
43
+ _event("assistant.message", {"content": "Done, PR is up.\n"}),
44
+ _event("result", exitCode=0, sessionId=SESSION),
45
+ )
46
+
47
+ self.assertEqual(self._run(_completed(stdout)), "Done, PR is up.")
48
+
49
+ def test_ignores_trailing_messages_that_only_call_tools(self) -> None:
50
+ stdout = _stream(
51
+ _event("assistant.message", {"content": "The answer is 42."}),
52
+ _event("assistant.message", {"content": "", "toolRequests": [{}]}),
53
+ _event("result", exitCode=0),
54
+ )
55
+
56
+ self.assertEqual(self._run(_completed(stdout)), "The answer is 42.")
57
+
58
+ def test_passes_the_session_id_and_runs_headless(self) -> None:
59
+ stdout = _stream(_event("assistant.message", {"content": "ok"}),
60
+ _event("result", exitCode=0))
61
+
62
+ self._run(_completed(stdout))
63
+
64
+ cmd = self.captured.call_args.args[0]
65
+ self.assertEqual(cmd[:2], ["copilot", "-p"])
66
+ self.assertEqual(cmd[2], "/do-it CORE-1")
67
+ self.assertIn("--session-id", cmd)
68
+ self.assertEqual(cmd[cmd.index("--session-id") + 1], SESSION)
69
+ for flag in ("--allow-all", "--no-ask-user", "--output-format"):
70
+ self.assertIn(flag, cmd)
71
+ self.assertEqual(self.captured.call_args.kwargs["cwd"], Path("/repo"))
72
+
73
+ def test_the_skill_model_is_passed_on_the_command_line(self) -> None:
74
+ stdout = _stream(_event("assistant.message", {"content": "ok"}),
75
+ _event("result", exitCode=0))
76
+
77
+ self._run(_completed(stdout), model="claude-opus-5")
78
+
79
+ cmd = self.captured.call_args.args[0]
80
+ self.assertEqual(cmd[cmd.index("--model") + 1], "claude-opus-5")
81
+
82
+ def test_no_model_leaves_the_agent_on_its_default(self) -> None:
83
+ stdout = _stream(_event("assistant.message", {"content": "ok"}),
84
+ _event("result", exitCode=0))
85
+
86
+ self._run(_completed(stdout))
87
+
88
+ self.assertNotIn("--model", self.captured.call_args.args[0])
89
+
90
+ def test_a_non_zero_exit_raises_with_the_stderr_reason(self) -> None:
91
+ completed = _completed(
92
+ stderr='Error: Model "nope" from --model flag is not available.',
93
+ returncode=1)
94
+
95
+ with self.assertRaises(RuntimeError) as caught:
96
+ self._run(completed)
97
+
98
+ self.assertIn("is not available", str(caught.exception))
99
+
100
+ def test_a_failed_run_raises_with_the_session_error(self) -> None:
101
+ stdout = _stream(
102
+ _event("session.error", {"errorType": "quota", "message": "quota exceeded"}),
103
+ _event("result", exitCode=1),
104
+ )
105
+
106
+ with self.assertRaises(RuntimeError) as caught:
107
+ self._run(_completed(stdout))
108
+
109
+ self.assertIn("quota exceeded", str(caught.exception))
110
+
111
+ def test_a_run_with_no_response_raises(self) -> None:
112
+ with self.assertRaises(RuntimeError):
113
+ self._run(_completed(_stream(_event("result", exitCode=0))))
114
+
115
+ def test_output_that_is_not_the_event_stream_is_passed_through(self) -> None:
116
+ self.assertEqual(self._run(_completed("plain text reply\n")),
117
+ "plain text reply\n")
118
+
119
+ def test_a_timeout_raises(self) -> None:
120
+ with patch("subprocess.run",
121
+ side_effect=subprocess.TimeoutExpired(cmd="copilot", timeout=7200)):
122
+ with self.assertRaises(RuntimeError):
123
+ self.agent.run("/do-it CORE-1", SESSION)
124
+
125
+
126
+ class CopilotModelCatalogTest(unittest.TestCase):
127
+ def _queue(self, *lines: str) -> "queue.Queue[str]":
128
+ lines_queue: queue.Queue[str] = queue.Queue()
129
+ for line in lines:
130
+ lines_queue.put(line)
131
+ return lines_queue
132
+
133
+ def test_reads_the_session_new_result_past_other_traffic(self) -> None:
134
+ lines = self._queue(
135
+ json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": 1}}),
136
+ json.dumps({"jsonrpc": "2.0", "method": "session/update", "params": {}}),
137
+ json.dumps({"jsonrpc": "2.0", "id": 2, "result": {"sessionId": "s1"}}),
138
+ )
139
+
140
+ result = _await_result(Mock(poll=Mock(return_value=None)), lines, 2)
141
+
142
+ self.assertEqual(result["sessionId"], "s1")
143
+
144
+ def test_an_early_exit_raises_instead_of_waiting_out_the_deadline(self) -> None:
145
+ process = Mock(poll=Mock(return_value=1), returncode=1,
146
+ stderr=Mock(read=Mock(return_value="not logged in")))
147
+
148
+ with self.assertRaises(RuntimeError) as caught:
149
+ _await_result(process, self._queue(), 2)
150
+
151
+ self.assertIn("not logged in", str(caught.exception))
152
+
153
+ def test_a_catalog_becomes_id_and_name_pairs(self) -> None:
154
+ result = {"models": {"availableModels": [
155
+ {"modelId": "claude-opus-5", "name": "Claude Opus 5"},
156
+ {"modelId": "gpt-5.4"}, # no display name: falls back to the id
157
+ {"name": "nameless"}, # no id at all: unusable, skipped
158
+ ]}}
159
+
160
+ with patch("codee_agent_github_copilot.provider.subprocess.Popen"), \
161
+ patch("codee_agent_github_copilot.provider._send"), \
162
+ patch("codee_agent_github_copilot.provider._await_result",
163
+ return_value=result):
164
+ models = GitHubCopilotAgent.list_models()
165
+
166
+ self.assertEqual([(m.id, m.name) for m in models],
167
+ [("claude-opus-5", "Claude Opus 5"), ("gpt-5.4", "gpt-5.4")])
168
+
169
+ def test_an_unavailable_cli_yields_no_models_rather_than_raising(self) -> None:
170
+ with patch("codee_agent_github_copilot.provider.subprocess.Popen",
171
+ side_effect=FileNotFoundError("copilot")):
172
+ self.assertEqual(GitHubCopilotAgent.list_models(), [])
173
+
174
+
175
+ if __name__ == "__main__":
176
+ unittest.main()
File without changes
@@ -0,0 +1,13 @@
1
+ from sqlite3 import Connection
2
+ import sqlite3
3
+
4
+ from anyio import Path
5
+ from codee_main_context.context import CodeeMainContext
6
+
7
+
8
+ def _get_db_path(main_context: CodeeMainContext) -> Path:
9
+ return main_context.data_dir / "codee.db"
10
+
11
+
12
+ def get_db_connection(main_context: CodeeMainContext) -> Connection:
13
+ return sqlite3.connect(_get_db_path(main_context))
@@ -0,0 +1,148 @@
1
+ """SQLite storage for OAuth tokens, keyed by tasks provider.
2
+
3
+ Tokens are kept out of ``settings.json`` because that file is written by the
4
+ admin UI on every save and is easy to hand-edit or copy around; a refresh token
5
+ is a long-lived credential and belongs next to the rest of the runtime state.
6
+
7
+ Unlike :mod:`codee.lib.runs_db` these calls raise rather than swallow errors:
8
+ losing a token write silently would leave the UI claiming a connection that
9
+ doesn't exist, and a read that quietly returns ``None`` would look like "never
10
+ connected" and send the user through the whole consent flow again.
11
+ """
12
+ from contextlib import closing
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+ from codee_main_context.context import CodeeMainContext
16
+
17
+ from codee_database.database import get_db_connection
18
+
19
+ # An authorization redirect that hasn't come back within this window is treated
20
+ # as abandoned, so a stale row can't be replayed later.
21
+ PENDING_TTL = timedelta(minutes=10)
22
+
23
+ _TOKEN_COLUMNS = ("provider", "access_token", "refresh_token", "expires_at",
24
+ "scope", "account", "updated_at")
25
+ _PENDING_COLUMNS = ("state", "provider", "code_verifier",
26
+ "redirect_uri", "created_at")
27
+
28
+
29
+ def init(main_context: CodeeMainContext) -> None:
30
+ """Create the token and pending-authorization tables if absent. Idempotent."""
31
+ with closing(get_db_connection(main_context)) as conn, conn:
32
+ conn.execute(
33
+ """CREATE TABLE IF NOT EXISTS oauth_tokens (
34
+ provider TEXT PRIMARY KEY,
35
+ access_token TEXT NOT NULL,
36
+ refresh_token TEXT,
37
+ expires_at TEXT,
38
+ scope TEXT,
39
+ account TEXT,
40
+ updated_at TEXT NOT NULL
41
+ )"""
42
+ )
43
+ # One row per authorization in flight: the CSRF state we handed to the
44
+ # identity provider, plus the PKCE verifier the callback has to send back.
45
+ conn.execute(
46
+ """CREATE TABLE IF NOT EXISTS oauth_pending (
47
+ state TEXT PRIMARY KEY,
48
+ provider TEXT NOT NULL,
49
+ code_verifier TEXT NOT NULL,
50
+ redirect_uri TEXT NOT NULL,
51
+ created_at TEXT NOT NULL
52
+ )"""
53
+ )
54
+
55
+
56
+ def save_tokens(
57
+ provider: str,
58
+ access_token: str,
59
+ refresh_token: str | None,
60
+ expires_at: str | None,
61
+ scope: str = "",
62
+ account: str = "",
63
+ main_context: CodeeMainContext = None,
64
+ ) -> None:
65
+ """Store (or replace) the tokens for a provider."""
66
+ init(main_context)
67
+ with closing(get_db_connection(main_context)) as conn, conn:
68
+ conn.execute(
69
+ "INSERT INTO oauth_tokens (provider, access_token, refresh_token,"
70
+ " expires_at, scope, account, updated_at)"
71
+ " VALUES (?, ?, ?, ?, ?, ?, ?)"
72
+ " ON CONFLICT(provider) DO UPDATE SET"
73
+ " access_token = excluded.access_token,"
74
+ " refresh_token = excluded.refresh_token,"
75
+ " expires_at = excluded.expires_at,"
76
+ " scope = excluded.scope,"
77
+ " account = excluded.account,"
78
+ " updated_at = excluded.updated_at",
79
+ (provider, access_token, refresh_token, expires_at, scope, account,
80
+ datetime.now(timezone.utc).isoformat()),
81
+ )
82
+
83
+
84
+ def load_tokens(provider: str, main_context: CodeeMainContext = None) -> dict | None:
85
+ """Return the stored tokens for a provider, or None if it was never connected."""
86
+ init(main_context)
87
+ with closing(get_db_connection(main_context)) as conn:
88
+ row = conn.execute(
89
+ "SELECT provider, access_token, refresh_token, expires_at, scope,"
90
+ " account, updated_at FROM oauth_tokens WHERE provider = ?",
91
+ (provider,),
92
+ ).fetchone()
93
+ return dict(zip(_TOKEN_COLUMNS, row)) if row else None
94
+
95
+
96
+ def delete_tokens(provider: str, main_context: CodeeMainContext = None) -> None:
97
+ """Forget a provider's tokens, so the UI reports it as disconnected."""
98
+ init(main_context)
99
+ with closing(get_db_connection(main_context)) as conn, conn:
100
+ conn.execute("DELETE FROM oauth_tokens WHERE provider = ?", (provider,))
101
+
102
+
103
+ def create_pending(
104
+ provider: str,
105
+ state: str,
106
+ code_verifier: str,
107
+ redirect_uri: str,
108
+ main_context: CodeeMainContext = None,
109
+ ) -> None:
110
+ """Record an authorization we're about to send the browser off to."""
111
+ init(main_context)
112
+ with closing(get_db_connection(main_context)) as conn, conn:
113
+ _purge_expired_pending(conn)
114
+ conn.execute(
115
+ "INSERT OR REPLACE INTO oauth_pending (state, provider, code_verifier,"
116
+ " redirect_uri, created_at) VALUES (?, ?, ?, ?, ?)",
117
+ (state, provider, code_verifier, redirect_uri,
118
+ datetime.now(timezone.utc).isoformat()),
119
+ )
120
+
121
+
122
+ def consume_pending(
123
+ provider: str,
124
+ state: str,
125
+ main_context: CodeeMainContext = None,
126
+ ) -> dict | None:
127
+ """Take the pending authorization matching ``state``, removing it.
128
+
129
+ Returns None when the state is unknown, belongs to another provider, or has
130
+ expired — all of which mean the callback must be rejected. Single-use by
131
+ construction: the row is deleted whether or not the exchange later succeeds,
132
+ so a replayed callback finds nothing.
133
+ """
134
+ init(main_context)
135
+ with closing(get_db_connection(main_context)) as conn, conn:
136
+ _purge_expired_pending(conn)
137
+ row = conn.execute(
138
+ "SELECT state, provider, code_verifier, redirect_uri, created_at"
139
+ " FROM oauth_pending WHERE state = ? AND provider = ?",
140
+ (state, provider),
141
+ ).fetchone()
142
+ conn.execute("DELETE FROM oauth_pending WHERE state = ?", (state,))
143
+ return dict(zip(_PENDING_COLUMNS, row)) if row else None
144
+
145
+
146
+ def _purge_expired_pending(conn) -> None:
147
+ cutoff = (datetime.now(timezone.utc) - PENDING_TTL).isoformat()
148
+ conn.execute("DELETE FROM oauth_pending WHERE created_at < ?", (cutoff,))
File without changes
@@ -0,0 +1,127 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+
7
+
8
+ def project_root() -> Path:
9
+ """Directory holding the content Codee operates on: ``.claude/skills`` and ``memory/``.
10
+
11
+ Defaults to the working directory, so a project that installs Codee as a
12
+ package supplies its own skills and memories rather than reaching into the
13
+ installed package. Override with ``CODEE_PROJECT_ROOT``.
14
+ """
15
+ return Path(os.environ.get("CODEE_PROJECT_ROOT") or os.getcwd())
16
+
17
+
18
+ def skills_dir(root: Path | None = None) -> Path:
19
+ return (root or project_root()) / ".claude" / "skills"
20
+
21
+
22
+ def memory_dir(root: Path | None = None) -> Path:
23
+ return (root or project_root()) / "memory"
24
+
25
+
26
+ def data_dir(root: Path | None = None) -> Path:
27
+ """Codee's own state directory (settings.json, runs db). Override with ``CODEE_DATA_DIR``."""
28
+ override = os.environ.get("CODEE_DATA_DIR")
29
+ return Path(override) if override else (root or project_root()) / ".codee"
30
+
31
+
32
+ class TasksProvider(str, Enum):
33
+ JIRA = "jira"
34
+ AZURE_DEVOPS = "azure_devops"
35
+
36
+
37
+ class CodingAgent(str, Enum):
38
+ CLAUDE_CODE = "claude_code"
39
+ GITHUB_COPILOT = "github_copilot"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CredentialField:
44
+ key: str
45
+ label: str
46
+ secret: bool = False
47
+ default: str = ""
48
+
49
+
50
+ # Credential fields each provider needs. Keyed by provider so the admin UI can
51
+ # render the right inputs and settings.json can store per-provider values.
52
+ TASKS_PROVIDER_FIELDS: dict[TasksProvider, list[CredentialField]] = {
53
+ TasksProvider.JIRA: [
54
+ CredentialField("base_url", "Base URL"),
55
+ CredentialField("account_email", "Account email"),
56
+ CredentialField("api_token", "API token", secret=True),
57
+ CredentialField("project", "Project key"),
58
+ ],
59
+ # Azure DevOps authenticates through an Entra ID app registration, so the
60
+ # stored credentials describe the app; the tokens it yields live in SQLite
61
+ # (codee_database.oauth_tokens) rather than in settings.json.
62
+ TasksProvider.AZURE_DEVOPS: [
63
+ CredentialField("organization_url", "Organization URL"),
64
+ CredentialField("project", "Project"),
65
+ CredentialField("tenant_id", "Directory (tenant) ID"),
66
+ CredentialField("client_id", "Application (client) ID"),
67
+ CredentialField("client_secret", "Client secret", secret=True),
68
+ ],
69
+ }
70
+
71
+
72
+ @dataclass
73
+ class Settings:
74
+ tasks_provider: TasksProvider = TasksProvider.JIRA
75
+ # Which coding agent the executor drives to work on tasks.
76
+ coding_agent: CodingAgent = CodingAgent.CLAUDE_CODE
77
+ # Per-provider credentials, keyed by provider value -> {field key: value}.
78
+ # Values for all providers are kept so switching provider preserves them.
79
+ credentials: dict[str, dict[str, str]] = field(default_factory=dict)
80
+ # Max coding-agent runs the executor keeps in flight at once (>= 1).
81
+ max_parallel_agents: int = 3
82
+
83
+
84
+ @dataclass
85
+ class CodeeMainContext:
86
+ data_dir: Path
87
+ settings: Settings = field(default_factory=Settings)
88
+
89
+
90
+ def settings_file(data_dir: Path) -> Path:
91
+ return Path(data_dir) / "settings.json"
92
+
93
+
94
+ def load_settings(data_dir: Path) -> Settings:
95
+ """Load Settings from ``settings.json`` in data_dir, or defaults if absent."""
96
+ path = settings_file(data_dir)
97
+ if path.exists():
98
+ try:
99
+ data = json.loads(path.read_text())
100
+ return Settings(
101
+ tasks_provider=TasksProvider(data["tasks_provider"]),
102
+ coding_agent=CodingAgent(
103
+ data.get("coding_agent", CodingAgent.CLAUDE_CODE.value)),
104
+ credentials=data.get("credentials", {}),
105
+ max_parallel_agents=max(1, int(data.get("max_parallel_agents", 3))))
106
+ except (json.JSONDecodeError, OSError, KeyError, ValueError):
107
+ pass
108
+ return Settings()
109
+
110
+
111
+ def save_settings(data_dir: Path, settings: Settings) -> None:
112
+ """Persist Settings to ``settings.json`` in data_dir.
113
+
114
+ Written through a temp file and renamed into place: the executor re-reads
115
+ this file on every poll, and a torn read there would look like "no settings"
116
+ and silently reset it to the defaults.
117
+ """
118
+ path = settings_file(data_dir)
119
+ payload = json.dumps({
120
+ "tasks_provider": settings.tasks_provider.value,
121
+ "coding_agent": settings.coding_agent.value,
122
+ "credentials": settings.credentials,
123
+ "max_parallel_agents": settings.max_parallel_agents,
124
+ }, indent=2) + "\n"
125
+ temp = path.with_name(path.name + ".tmp")
126
+ temp.write_text(payload)
127
+ os.replace(temp, path)
@@ -0,0 +1,111 @@
1
+ """Process-wide logging for Codee's entry points.
2
+
3
+ Codee runs as several processes -- ``codee-start`` launches the executor and
4
+ the admin UI as subprocesses -- so debug mode is carried in the environment:
5
+ ``CODEE_DEBUG=1`` switches this package to ``DEBUG`` level, and because
6
+ subprocesses inherit the environment, one flag on the launcher turns on debug
7
+ logging everywhere.
8
+
9
+ Logging in any module::
10
+
11
+ from codee_main_context.logging import get_logger
12
+
13
+ log = get_logger(__name__)
14
+ log.debug("fetched %d task(s) from %s", len(tasks), provider.describe())
15
+
16
+ Only entry points (``main()`` functions) call :func:`configure_logging`, and
17
+ they call it once, before doing any work. Everything else just asks for a
18
+ logger and logs; the handler is the entry point's business.
19
+ """
20
+ import logging
21
+ import os
22
+ import sys
23
+ from typing import TextIO
24
+
25
+ # Set to "1"/"true"/"yes"/"on" for Codee debug output, or "all" to also
26
+ # un-mute the chatty third-party libraries listed below.
27
+ DEBUG_ENV_VAR = "CODEE_DEBUG"
28
+
29
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
30
+ _VERBOSE = "all"
31
+
32
+ # Libraries whose DEBUG output would bury ours (a single boto3 call emits
33
+ # dozens of lines). Held at INFO unless CODEE_DEBUG=all asks for everything.
34
+ _NOISY_LOGGERS = (
35
+ "asyncio",
36
+ "boto3",
37
+ "botocore",
38
+ "httpcore",
39
+ "httpx",
40
+ "reflex",
41
+ "s3transfer",
42
+ "urllib3",
43
+ "watchdog",
44
+ "watchfiles",
45
+ )
46
+
47
+ _FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s"
48
+ _DEBUG_FORMAT = "%(asctime)s %(levelname)-7s %(name)s:%(lineno)d %(funcName)s: %(message)s"
49
+ _DATE_FORMAT = "%H:%M:%S"
50
+
51
+
52
+ def _flag() -> str:
53
+ return os.environ.get(DEBUG_ENV_VAR, "").strip().lower()
54
+
55
+
56
+ def debug_enabled() -> bool:
57
+ """True when ``CODEE_DEBUG`` asks for debug output."""
58
+ return _flag() in _TRUTHY or _flag() == _VERBOSE
59
+
60
+
61
+ def verbose_debug_enabled() -> bool:
62
+ """True for ``CODEE_DEBUG=all``: our debug output plus the noisy libraries'."""
63
+ return _flag() == _VERBOSE
64
+
65
+
66
+ def enable_debug(*, verbose: bool = False) -> None:
67
+ """Turn debug on for this process and every subprocess it goes on to spawn.
68
+
69
+ Writing to ``os.environ`` rather than to a module global is what makes
70
+ ``codee-start --debug`` reach the executor and admin processes, which are
71
+ separate interpreters that inherit the environment.
72
+ """
73
+ os.environ[DEBUG_ENV_VAR] = _VERBOSE if verbose else "1"
74
+
75
+
76
+ def configure_logging(debug: bool | None = None, *,
77
+ stream: TextIO | None = None) -> None:
78
+ """Install Codee's log handler. Call once, from an entry point.
79
+
80
+ ``debug=None`` (the default) takes the level from ``CODEE_DEBUG``; passing
81
+ ``debug=True`` also exports the flag so subprocesses inherit it. Safe to
82
+ call more than once -- the previous handler is replaced, not stacked.
83
+ """
84
+ if debug:
85
+ enable_debug()
86
+ if debug is None:
87
+ debug = debug_enabled()
88
+
89
+ logging.basicConfig(
90
+ level=logging.DEBUG if debug else logging.INFO,
91
+ format=_DEBUG_FORMAT if debug else _FORMAT,
92
+ datefmt=_DATE_FORMAT,
93
+ # stdout, not the logging default of stderr: the code around these logs
94
+ # still prints to stdout, and splitting the two streams would scramble
95
+ # their order as soon as the output is piped to a file.
96
+ stream=stream or sys.stdout,
97
+ force=True,
98
+ )
99
+
100
+ if not verbose_debug_enabled():
101
+ for name in _NOISY_LOGGERS:
102
+ logging.getLogger(name).setLevel(logging.INFO)
103
+
104
+
105
+ def get_logger(name: str) -> logging.Logger:
106
+ """Logger for a module; pass ``__name__``.
107
+
108
+ A thin wrapper over ``logging.getLogger`` so modules have one import for
109
+ logging and never touch handler configuration by accident.
110
+ """
111
+ return logging.getLogger(name)
@@ -0,0 +1,90 @@
1
+ import io
2
+ import logging
3
+ import os
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from codee_main_context.logging import (
8
+ DEBUG_ENV_VAR, configure_logging, debug_enabled, enable_debug, get_logger,
9
+ verbose_debug_enabled)
10
+
11
+
12
+ class LoggingTest(unittest.TestCase):
13
+ def setUp(self) -> None:
14
+ root = logging.getLogger()
15
+ self._handlers = root.handlers[:]
16
+ self._level = root.level
17
+ self.stream = io.StringIO()
18
+
19
+ def tearDown(self) -> None:
20
+ root = logging.getLogger()
21
+ root.handlers[:] = self._handlers
22
+ root.setLevel(self._level)
23
+ logging.getLogger("botocore").setLevel(logging.NOTSET)
24
+
25
+ def test_debug_enabled_reads_the_environment(self) -> None:
26
+ for value in ("1", "true", "TRUE", "yes", " on ", "all"):
27
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: value}):
28
+ self.assertTrue(debug_enabled(), value)
29
+
30
+ for value in ("", "0", "false", "no"):
31
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: value}):
32
+ self.assertFalse(debug_enabled(), value)
33
+
34
+ def test_verbose_only_for_all(self) -> None:
35
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: "1"}):
36
+ self.assertFalse(verbose_debug_enabled())
37
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: "all"}):
38
+ self.assertTrue(verbose_debug_enabled())
39
+
40
+ def test_enable_debug_exports_the_flag_for_subprocesses(self) -> None:
41
+ with patch.dict(os.environ, {}, clear=False):
42
+ os.environ.pop(DEBUG_ENV_VAR, None)
43
+ enable_debug()
44
+ self.assertEqual(os.environ[DEBUG_ENV_VAR], "1")
45
+ enable_debug(verbose=True)
46
+ self.assertEqual(os.environ[DEBUG_ENV_VAR], "all")
47
+
48
+ def test_debug_messages_are_dropped_by_default(self) -> None:
49
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: ""}):
50
+ configure_logging(stream=self.stream)
51
+ log = get_logger("codee.sample")
52
+ log.debug("hidden detail")
53
+ log.info("visible line")
54
+
55
+ output = self.stream.getvalue()
56
+ self.assertNotIn("hidden detail", output)
57
+ self.assertIn("visible line", output)
58
+
59
+ def test_debug_messages_appear_when_enabled(self) -> None:
60
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: "1"}):
61
+ configure_logging(stream=self.stream)
62
+ get_logger("codee.sample").debug("task %s queued", "NIM-1")
63
+
64
+ output = self.stream.getvalue()
65
+ self.assertIn("DEBUG", output)
66
+ self.assertIn("task NIM-1 queued", output)
67
+ self.assertIn("codee.sample", output)
68
+
69
+ def test_explicit_debug_also_exports_the_flag(self) -> None:
70
+ with patch.dict(os.environ, {}, clear=False):
71
+ os.environ.pop(DEBUG_ENV_VAR, None)
72
+ configure_logging(debug=True, stream=self.stream)
73
+ self.assertEqual(os.environ[DEBUG_ENV_VAR], "1")
74
+ self.assertEqual(logging.getLogger().level, logging.DEBUG)
75
+
76
+ def test_noisy_libraries_stay_quiet_until_debug_all(self) -> None:
77
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: "1"}):
78
+ configure_logging(stream=self.stream)
79
+ self.assertEqual(logging.getLogger(
80
+ "botocore").level, logging.INFO)
81
+
82
+ with patch.dict(os.environ, {DEBUG_ENV_VAR: "all"}):
83
+ logging.getLogger("botocore").setLevel(logging.NOTSET)
84
+ configure_logging(stream=self.stream)
85
+ self.assertEqual(logging.getLogger(
86
+ "botocore").level, logging.NOTSET)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ unittest.main()
File without changes