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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
lemming/api/loop_test.py
ADDED
|
@@ -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")
|