lemming-cli 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 (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,94 @@
1
+ import pathlib
2
+ import shutil
3
+ import tempfile
4
+
5
+ from lemming import api
6
+
7
+
8
+ def test_list_directories(client, test_tasks):
9
+ """GET /api/directories lists subdirectories under the server root."""
10
+ root = api.app.state.root
11
+ (root / "subproject_a").mkdir(exist_ok=True)
12
+ (root / "subproject_b").mkdir(exist_ok=True)
13
+ (root / ".hidden").mkdir(exist_ok=True)
14
+
15
+ response = client.get("/api/directories")
16
+ assert response.status_code == 200
17
+ data = response.json()
18
+ names = [d["name"] for d in data["directories"]]
19
+ assert "subproject_a" in names
20
+ assert "subproject_b" in names
21
+ assert ".hidden" not in names # hidden dirs excluded
22
+
23
+
24
+ def test_create_directory(client, test_tasks):
25
+ """POST /api/directories creates a new directory."""
26
+ root = api.app.state.root
27
+ response = client.post("/api/directories", json={"name": "new_dir"})
28
+ assert response.status_code == 200
29
+ data = response.json()
30
+ assert data["name"] == "new_dir"
31
+ assert data["path"] == "new_dir"
32
+ assert (root / "new_dir").is_dir()
33
+
34
+ # Test creating in a subdirectory
35
+ (root / "parent").mkdir()
36
+ response = client.post(
37
+ "/api/directories", json={"path": "parent", "name": "child"}
38
+ )
39
+ assert response.status_code == 200
40
+ assert (root / "parent" / "child").is_dir()
41
+
42
+
43
+ def test_create_directory_exists(client, test_tasks):
44
+ """POST /api/directories fails if directory already exists."""
45
+ root = api.app.state.root
46
+ (root / "existing").mkdir()
47
+ response = client.post("/api/directories", json={"name": "existing"})
48
+ assert response.status_code == 400
49
+ assert "already exists" in response.json()["detail"]
50
+
51
+
52
+ def test_create_directory_traversal(client, test_tasks):
53
+ """POST /api/directories rejects path traversal."""
54
+ response = client.post(
55
+ "/api/directories", json={"path": "../../etc", "name": "foo"}
56
+ )
57
+ assert response.status_code == 403
58
+
59
+ response = client.post("/api/directories", json={"name": "../outside"})
60
+ assert response.status_code == 403
61
+
62
+
63
+ def test_list_directories_traversal(client, test_tasks):
64
+ """GET /api/directories rejects path traversal."""
65
+ response = client.get("/api/directories", params={"path": "../../etc"})
66
+ assert response.status_code == 403
67
+
68
+
69
+ def test_project_param_traversal_rejected(client, test_tasks):
70
+ """project param rejects path traversal attempts."""
71
+ response = client.get("/api/data", params={"project": "../../etc"})
72
+ assert response.status_code == 403
73
+
74
+
75
+ def test_symlink_traversal_rejected(client, test_tasks):
76
+ """Symlinks pointing outside the root are rejected."""
77
+ root = api.app.state.root
78
+ external_dir = pathlib.Path(tempfile.mkdtemp())
79
+ try:
80
+ symlink = root / "sneaky_link"
81
+ symlink.symlink_to(external_dir)
82
+
83
+ # /api/directories should not list it (hidden by . filter won't help,
84
+ # but resolve_tasks_file and list_directories should reject traversal)
85
+ response = client.get("/api/data", params={"project": "sneaky_link"})
86
+ assert response.status_code == 403
87
+
88
+ response = client.get(
89
+ "/api/directories", params={"path": "sneaky_link"}
90
+ )
91
+ assert response.status_code == 403
92
+ finally:
93
+ symlink.unlink(missing_ok=True)
94
+ shutil.rmtree(external_dir)
lemming/api/files.py ADDED
@@ -0,0 +1,116 @@
1
+ """API routes for the file browser and raw file serving."""
2
+
3
+ import importlib.resources
4
+ import mimetypes
5
+ import pathlib
6
+
7
+ import fastapi
8
+ import fastapi.responses
9
+
10
+ from .. import paths
11
+
12
+ router = fastapi.APIRouter()
13
+
14
+ # Get the web directory for serving HTML templates
15
+ web_dir = pathlib.Path(
16
+ str(importlib.resources.files("lemming").joinpath("web"))
17
+ )
18
+
19
+
20
+ @router.get("/api/files/{path:path}")
21
+ def get_files_api(request: fastapi.Request, path: str):
22
+ """List the contents of a directory under the server root."""
23
+ base_path = request.app.state.root
24
+ target_path = (base_path / path).resolve()
25
+
26
+ if not target_path.is_relative_to(base_path) or paths.is_ignored(
27
+ target_path
28
+ ):
29
+ raise fastapi.HTTPException(403, "Forbidden")
30
+
31
+ if not target_path.is_dir():
32
+ raise fastapi.HTTPException(400, "Not a directory")
33
+
34
+ contents = []
35
+ for item in target_path.iterdir():
36
+ if paths.is_ignored(item):
37
+ continue
38
+ try:
39
+ stats = item.stat()
40
+ except OSError:
41
+ continue
42
+ rel_path = item.relative_to(base_path)
43
+ is_dir = item.is_dir()
44
+ contents.append(
45
+ {
46
+ "name": item.name + ("/" if is_dir else ""),
47
+ "path": str(rel_path),
48
+ "is_dir": is_dir,
49
+ "size": None if is_dir else stats.st_size,
50
+ "modified": stats.st_mtime,
51
+ }
52
+ )
53
+ return {
54
+ "path": path,
55
+ "contents": sorted(
56
+ contents, key=lambda x: (not x["is_dir"], x["name"].lower())
57
+ ),
58
+ }
59
+
60
+
61
+ @router.get("/tasks/{task_id}/log")
62
+ def serve_task_log(task_id: str):
63
+ """Serve the log viewer page for a task."""
64
+ return fastapi.responses.FileResponse(web_dir / "logs.html")
65
+
66
+
67
+ @router.get("/files/{path:path}")
68
+ def serve_files(request: fastapi.Request, path: str):
69
+ """Serve a file's contents, or the file browser page for directories."""
70
+ base_path = request.app.state.root
71
+ target_path = (base_path / path).resolve()
72
+
73
+ if not target_path.is_relative_to(base_path) or paths.is_ignored(
74
+ target_path
75
+ ):
76
+ raise fastapi.HTTPException(403, "Forbidden")
77
+
78
+ if target_path.is_dir():
79
+ return fastapi.responses.FileResponse(web_dir / "files.html")
80
+ if target_path.is_file():
81
+ # Guess the MIME type to identify binary formats.
82
+ guess, _ = mimetypes.guess_type(target_path)
83
+
84
+ # Consider images, video, audio, PDFs, and common archives as
85
+ # "binary" to be served as-is.
86
+ is_binary = guess and (
87
+ guess.startswith(("image/", "video/", "audio/"))
88
+ or guess
89
+ in (
90
+ "application/pdf",
91
+ "application/wasm",
92
+ "application/zip",
93
+ "application/x-zip-compressed",
94
+ )
95
+ )
96
+
97
+ # Special case: .ts files are frequently misidentified as video/mp2t.
98
+ if is_binary and target_path.suffix.lower() == ".ts":
99
+ is_binary = False
100
+
101
+ if is_binary:
102
+ return fastapi.responses.FileResponse(target_path)
103
+
104
+ # For everything else, force text/plain to ensure browser views
105
+ # source code.
106
+ return fastapi.responses.FileResponse(
107
+ target_path, media_type="text/plain"
108
+ )
109
+
110
+ raise fastapi.HTTPException(404, "Not found")
111
+
112
+
113
+ @router.get("/files")
114
+ def redirect_files():
115
+ """Redirect /files to the file browser at the root directory."""
116
+ return fastapi.responses.RedirectResponse("/files/")
@@ -0,0 +1,163 @@
1
+ def test_root_redirect(client, git_repo):
2
+ response = client.get("/files", follow_redirects=False)
3
+ assert response.status_code in [302, 307, 301]
4
+ assert response.headers["location"].endswith("/files/")
5
+
6
+
7
+ def test_list_root(client, git_repo):
8
+ # Test template response
9
+ response = client.get("/files/")
10
+ assert response.status_code == 200
11
+ assert "Lemming" in response.text
12
+
13
+ # Test API response
14
+ response = client.get("/api/files/")
15
+ assert response.status_code == 200
16
+ data = response.json()
17
+ names = [item["name"] for item in data["contents"]]
18
+ assert "file1.txt" in names
19
+ assert "dir1/" in names
20
+ assert "ignored.txt" not in names
21
+ assert "node_modules/" not in names
22
+
23
+ # Check metadata
24
+ file1 = next(
25
+ item for item in data["contents"] if item["name"] == "file1.txt"
26
+ )
27
+ assert file1["size"] == 8 # "content1"
28
+ assert "modified" in file1
29
+
30
+
31
+ def test_list_subdir(client, git_repo):
32
+ # Test template response
33
+ response = client.get("/files/dir1")
34
+ assert response.status_code == 200
35
+ assert "Lemming" in response.text
36
+
37
+ # Test API response
38
+ response = client.get("/api/files/dir1")
39
+ assert response.status_code == 200
40
+ data = response.json()
41
+ names = [item["name"] for item in data["contents"]]
42
+ assert "file2.txt" in names
43
+
44
+
45
+ def test_serve_file(client, git_repo):
46
+ response = client.get("/files/file1.txt")
47
+ assert response.status_code == 200
48
+ assert response.text == "content1"
49
+ assert "text/plain" in response.headers["content-type"]
50
+
51
+
52
+ def test_serve_html_as_text(client, git_repo):
53
+ html_file = git_repo / "test.html"
54
+ html_file.write_text("<html><body>hello</body></html>")
55
+ response = client.get("/files/test.html")
56
+ assert response.status_code == 200
57
+ assert "text/plain" in response.headers["content-type"]
58
+ assert response.text == "<html><body>hello</body></html>"
59
+
60
+
61
+ def test_serve_js_as_text(client, git_repo):
62
+ js_file = git_repo / "test.js"
63
+ js_file.write_text("console.log('hello');")
64
+ response = client.get("/files/test.js")
65
+ assert response.status_code == 200
66
+ assert "text/plain" in response.headers["content-type"]
67
+ assert response.text == "console.log('hello');"
68
+
69
+
70
+ def test_serve_ignored_file(client, git_repo):
71
+ response = client.get("/files/ignored.txt")
72
+ assert response.status_code == 403
73
+ assert "Forbidden" in response.text
74
+
75
+
76
+ def test_serve_nonexistent_file(client, git_repo):
77
+ response = client.get("/files/nonexistent.txt")
78
+ assert response.status_code == 404
79
+
80
+
81
+ def test_list_non_git_dir(client, non_git_dir):
82
+ """Files should be listed without errors when not in a git repo."""
83
+ response = client.get("/api/files/")
84
+ assert response.status_code == 200
85
+ data = response.json()
86
+ names = [item["name"] for item in data["contents"]]
87
+
88
+ # All files should be visible since there's no git to check ignore rules
89
+ assert "file1.txt" in names
90
+ assert "ignored.txt" in names
91
+
92
+
93
+ def test_serve_images_as_images(client, temp_repo):
94
+ png_file = temp_repo / "test.png"
95
+ png_file.write_bytes(b"fake png content")
96
+
97
+ response = client.get("/files/test.png")
98
+ assert response.status_code == 200
99
+ assert "image/png" in response.headers["content-type"]
100
+
101
+
102
+ def test_serve_pdf_as_pdf(client, temp_repo):
103
+ pdf_file = temp_repo / "test.pdf"
104
+ pdf_file.write_bytes(b"%PDF-1.4 fake pdf")
105
+
106
+ response = client.get("/files/test.pdf")
107
+ assert response.status_code == 200
108
+ assert "application/pdf" in response.headers["content-type"]
109
+
110
+
111
+ def test_serve_ts_as_text(client, temp_repo):
112
+ # .ts is often misidentified as video/mp2t
113
+ ts_file = temp_repo / "test.ts"
114
+ ts_file.write_text("export const a = 1;")
115
+
116
+ response = client.get("/files/test.ts")
117
+ assert response.status_code == 200
118
+ assert "text/plain" in response.headers["content-type"]
119
+
120
+
121
+ def test_serve_json_as_text(client, temp_repo):
122
+ json_file = temp_repo / "test.json"
123
+ json_file.write_text('{"a": 1}')
124
+
125
+ response = client.get("/files/test.json")
126
+ assert response.status_code == 200
127
+ assert "text/plain" in response.headers["content-type"]
128
+
129
+
130
+ def test_serve_unknown_extension_as_text(client, temp_repo):
131
+ unknown_file = temp_repo / "test.unknown_extension_xyz"
132
+ unknown_file.write_text("some content")
133
+
134
+ # In some environments, unknown extension might return octet-stream or None.
135
+ # Our API forces text/plain for anything not explicitly binary.
136
+ response = client.get("/files/test.unknown_extension_xyz")
137
+ assert response.status_code == 200
138
+ assert "text/plain" in response.headers["content-type"]
139
+
140
+
141
+ def test_serve_no_extension_as_text(client, temp_repo):
142
+ no_ext_file = temp_repo / "Dockerfile"
143
+ no_ext_file.write_text("FROM alpine")
144
+
145
+ response = client.get("/files/Dockerfile")
146
+ assert response.status_code == 200
147
+ assert "text/plain" in response.headers["content-type"]
148
+
149
+
150
+ def test_serve_zip_as_zip(client, temp_repo):
151
+ zip_file = temp_repo / "test.zip"
152
+ zip_file.write_bytes(b"fake zip content")
153
+
154
+ response = client.get("/files/test.zip")
155
+ assert response.status_code == 200
156
+ assert "zip" in response.headers["content-type"]
157
+
158
+
159
+ def test_serve_log_page(client, test_tasks):
160
+ # Try to access the log page for task1
161
+ resp = client.get("/tasks/task1/log")
162
+ assert resp.status_code == 200
163
+ assert "text/html" in resp.headers["content-type"]
lemming/api/hooks.py ADDED
@@ -0,0 +1,15 @@
1
+ """API routes for listing orchestrator hooks."""
2
+
3
+ import fastapi
4
+
5
+ from .. import prompts
6
+ from . import context
7
+
8
+ router = fastapi.APIRouter()
9
+
10
+
11
+ @router.get("/api/hooks")
12
+ def list_hooks(request: fastapi.Request, project: str | None = None):
13
+ """List all available orchestrator hooks (built-in and project-specific)."""
14
+ tasks_file = context.resolve_tasks_file(request.app.state, project)
15
+ return prompts.list_hooks(tasks_file)
@@ -0,0 +1,33 @@
1
+ from unittest.mock import patch
2
+
3
+ import lemming.api
4
+
5
+
6
+ def test_list_hooks(client, test_tasks):
7
+ """Test that the /api/hooks endpoint returns a list of available hooks."""
8
+ with patch("lemming.prompts.list_hooks") as mock_list_hooks:
9
+ mock_list_hooks.return_value = ["roadmap", "readability", "testing"]
10
+
11
+ response = client.get("/api/hooks")
12
+
13
+ assert response.status_code == 200
14
+ assert response.json() == ["roadmap", "readability", "testing"]
15
+ mock_list_hooks.assert_called_once()
16
+
17
+
18
+ def test_list_hooks_with_project(client, test_tasks):
19
+ """Test that the /api/hooks endpoint works with a specified project."""
20
+ root = lemming.api.app.state.root
21
+ project_dir = root / "project1"
22
+ project_dir.mkdir()
23
+ tasks_file = project_dir / "tasks.yml"
24
+ tasks_file.touch()
25
+
26
+ with patch("lemming.prompts.list_hooks") as mock_list_hooks:
27
+ mock_list_hooks.return_value = ["roadmap"]
28
+
29
+ response = client.get("/api/hooks", params={"project": "project1"})
30
+
31
+ assert response.status_code == 200
32
+ assert response.json() == ["roadmap"]
33
+ assert mock_list_hooks.called
lemming/api/logging.py ADDED
@@ -0,0 +1,16 @@
1
+ """Logging filters for the API server."""
2
+
3
+ import logging
4
+
5
+ # Paths that should not appear in the uvicorn access log
6
+ # (e.g. polling endpoints).
7
+ QUIET_PATHS = {"/api/data", "GET /api/tasks/", "/api/files"}
8
+
9
+
10
+ class QuietPollFilter(logging.Filter):
11
+ """Suppress access-log lines for high-frequency polling endpoints."""
12
+
13
+ def filter(self, record: logging.LogRecord) -> bool:
14
+ """Return False for log records that match a quiet path."""
15
+ msg = record.getMessage()
16
+ return not any(path in msg for path in QUIET_PATHS)
@@ -0,0 +1,59 @@
1
+ import logging
2
+
3
+ from lemming import api
4
+
5
+
6
+ def test_quiet_poll_filter():
7
+ """QuietPollFilter suppresses access-log lines for polling endpoints."""
8
+ filt = api.QuietPollFilter()
9
+
10
+ # Simulate a uvicorn access-log record for the polling endpoints.
11
+ for path in ("/api/data", "/api/files/burger", "/api/files"):
12
+ record = logging.LogRecord(
13
+ name="uvicorn.access",
14
+ level=logging.INFO,
15
+ pathname="",
16
+ lineno=0,
17
+ msg='%s - "%s %s HTTP/%s" %d',
18
+ args=("127.0.0.1:55964", "GET", path, "1.1", 200),
19
+ exc_info=None,
20
+ )
21
+ assert filt.filter(record) is False
22
+
23
+ # GET /api/tasks/{task_id} and GET /api/tasks/{task_id}/log should be
24
+ # quieted.
25
+ for path in ("/api/tasks/abc-123", "/api/tasks/xyz-789/log"):
26
+ record = logging.LogRecord(
27
+ name="uvicorn.access",
28
+ level=logging.INFO,
29
+ pathname="",
30
+ lineno=0,
31
+ msg='%s - "%s %s HTTP/%s" %d',
32
+ args=("127.0.0.1:55964", "GET", path, "1.1", 200),
33
+ exc_info=None,
34
+ )
35
+ assert filt.filter(record) is False
36
+
37
+ # Non-polling endpoints should still be logged.
38
+ record_other = logging.LogRecord(
39
+ name="uvicorn.access",
40
+ level=logging.INFO,
41
+ pathname="",
42
+ lineno=0,
43
+ msg='%s - "%s %s HTTP/%s" %d',
44
+ args=("127.0.0.1:55964", "GET", "/api/runners", "1.1", 200),
45
+ exc_info=None,
46
+ )
47
+ assert filt.filter(record_other) is True
48
+
49
+ # Important: POST /api/tasks should NOT be quieted.
50
+ record_post = logging.LogRecord(
51
+ name="uvicorn.access",
52
+ level=logging.INFO,
53
+ pathname="",
54
+ lineno=0,
55
+ msg='%s - "%s %s HTTP/%s" %d',
56
+ args=("127.0.0.1:55964", "POST", "/api/tasks/abc-123", "1.1", 200),
57
+ exc_info=None,
58
+ )
59
+ assert filt.filter(record_post) is True
lemming/api/loop.py ADDED
@@ -0,0 +1,45 @@
1
+ """Helpers to auto-start the orchestrator loop from API requests."""
2
+
3
+ import os
4
+ import pathlib
5
+ import subprocess
6
+ import sys
7
+
8
+ import fastapi
9
+
10
+ from .. import tasks
11
+
12
+
13
+ def start_loop_if_needed(
14
+ app_state: fastapi.datastructures.State,
15
+ tasks_file: pathlib.Path,
16
+ cwd: pathlib.Path | None = None,
17
+ ):
18
+ """Start the orchestrator loop unless disabled or already running."""
19
+ if getattr(app_state, "disable_auto_start", False):
20
+ return
21
+
22
+ if tasks.is_loop_running(tasks_file):
23
+ return
24
+
25
+ # Use sys.executable -m lemming.main to ensure we use the same environment
26
+ # and pass the explicit tasks file.
27
+ cmd = [
28
+ sys.executable,
29
+ "-m",
30
+ "lemming.main",
31
+ ]
32
+ if getattr(app_state, "verbose", False):
33
+ cmd.append("--verbose")
34
+ cmd.extend(
35
+ [
36
+ "--tasks-file",
37
+ str(tasks_file),
38
+ "run",
39
+ ]
40
+ )
41
+
42
+ # Start the loop in a new session so it outlives the request.
43
+ subprocess.Popen(
44
+ cmd, start_new_session=True, env=os.environ.copy(), cwd=cwd
45
+ )
@@ -0,0 +1,58 @@
1
+ import pathlib
2
+ import sys
3
+ from unittest import mock
4
+
5
+ from lemming import models, persistence
6
+ from lemming.api import loop
7
+
8
+
9
+ def test_start_loop_if_needed_already_running():
10
+ app_state = mock.Mock()
11
+ app_state.disable_auto_start = False
12
+ tasks_file = pathlib.Path("/tmp/tasks.yml")
13
+
14
+ with mock.patch("lemming.tasks.is_loop_running", return_value=True):
15
+ with mock.patch("subprocess.Popen") as mock_popen:
16
+ loop.start_loop_if_needed(app_state, tasks_file)
17
+ mock_popen.assert_not_called()
18
+
19
+
20
+ def test_start_loop_if_needed_disabled():
21
+ app_state = mock.Mock()
22
+ app_state.disable_auto_start = True
23
+ tasks_file = pathlib.Path("/tmp/tasks.yml")
24
+
25
+ with mock.patch("lemming.tasks.is_loop_running", return_value=False):
26
+ with mock.patch("subprocess.Popen") as mock_popen:
27
+ loop.start_loop_if_needed(app_state, tasks_file)
28
+ mock_popen.assert_not_called()
29
+
30
+
31
+ def test_start_loop_if_needed_starts_process(tmp_path):
32
+ app_state = mock.Mock()
33
+ app_state.disable_auto_start = False
34
+ app_state.verbose = True
35
+ tasks_file = tmp_path / "tasks.yml"
36
+ # Scaffold tasks file
37
+ persistence.save_tasks(tasks_file, models.Roadmap())
38
+ cwd = pathlib.Path("/tmp/cwd")
39
+
40
+ with mock.patch("lemming.tasks.is_loop_running", return_value=False):
41
+ with mock.patch("subprocess.Popen") as mock_popen:
42
+ loop.start_loop_if_needed(app_state, tasks_file, cwd=cwd)
43
+
44
+ expected_cmd = [
45
+ sys.executable,
46
+ "-m",
47
+ "lemming.main",
48
+ "--verbose",
49
+ "--tasks-file",
50
+ str(tasks_file),
51
+ "run",
52
+ ]
53
+ mock_popen.assert_called_once()
54
+ args, kwargs = mock_popen.call_args
55
+ assert args[0] == expected_cmd
56
+ assert kwargs["start_new_session"] is True
57
+ assert kwargs["cwd"] == cwd
58
+ assert "PATH" in kwargs["env"]
lemming/api/main.py ADDED
@@ -0,0 +1,53 @@
1
+ """FastAPI application setup: middleware, routers, and static files."""
2
+
3
+ import importlib.resources
4
+ import pathlib
5
+
6
+ import fastapi
7
+ import fastapi.responses
8
+ import fastapi.staticfiles
9
+
10
+ from .. import paths
11
+ from . import auth, config, directories, files, hooks, tasks
12
+ from . import logging as lemming_logging
13
+
14
+ # Re-export for backward compatibility and tests
15
+ QuietPollFilter = lemming_logging.QuietPollFilter
16
+
17
+
18
+ class FilteredStaticFiles(fastapi.staticfiles.StaticFiles):
19
+ """Subclass of StaticFiles that filters out web test files."""
20
+
21
+ def lookup_path(self, path: str):
22
+ """Hide .spec.js and .test.js files from static file lookups."""
23
+ if path.endswith(".spec.js") or path.endswith(".test.js"):
24
+ return "", None
25
+ return super().lookup_path(path)
26
+
27
+
28
+ app = fastapi.FastAPI()
29
+ app.state.tasks_file = paths.get_default_tasks_file()
30
+ app.state.root = pathlib.Path.cwd().resolve()
31
+ app.state.disable_auto_start = False
32
+
33
+ # Middleware
34
+ app.middleware("http")(auth.share_token_middleware)
35
+
36
+ # Include Routers
37
+ app.include_router(tasks.router)
38
+ app.include_router(files.router)
39
+ app.include_router(directories.router)
40
+ app.include_router(hooks.router)
41
+ app.include_router(config.router)
42
+
43
+ # Static files and root routes
44
+ web_dir = pathlib.Path(
45
+ str(importlib.resources.files("lemming").joinpath("web"))
46
+ )
47
+ app.mount("/static", FilteredStaticFiles(directory=web_dir), name="static")
48
+
49
+
50
+ @app.get("/")
51
+ def read_index():
52
+ """Serve the main web UI page."""
53
+ return fastapi.responses.FileResponse(web_dir / "index.html")