srcly 0.1.34__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {srcly-0.1.34 → srcly-0.2.0}/PKG-INFO +6 -6
- {srcly-0.1.34 → srcly-0.2.0}/README.md +3 -3
- srcly-0.2.0/app/config.py +139 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/main.py +11 -8
- srcly-0.2.0/app/models.py +105 -0
- srcly-0.2.0/app/routers/analysis.py +131 -0
- srcly-0.2.0/app/routers/compare.py +112 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/routers/files.py +26 -3
- {srcly-0.1.34 → srcly-0.2.0}/app/routers/scans.py +11 -6
- {srcly-0.1.34 → srcly-0.2.0}/app/run.py +59 -1
- {srcly-0.1.34 → srcly-0.2.0}/app/services/analysis.py +591 -65
- {srcly-0.1.34 → srcly-0.2.0}/app/services/analysis_types.py +9 -0
- srcly-0.2.0/app/services/cache.py +261 -0
- srcly-0.2.0/app/services/compare/__init__.py +8 -0
- srcly-0.2.0/app/services/compare/diff.py +315 -0
- srcly-0.2.0/app/services/compare/git_refs.py +185 -0
- srcly-0.2.0/app/services/compare/jobs.py +150 -0
- srcly-0.2.0/app/services/compare/service.py +118 -0
- srcly-0.2.0/app/services/compare/snapshots.py +276 -0
- srcly-0.2.0/app/services/deps/__init__.py +0 -0
- srcly-0.2.0/app/services/deps/common.py +191 -0
- srcly-0.2.0/app/services/deps/graph.py +171 -0
- srcly-0.2.0/app/services/deps/python_deps.py +402 -0
- srcly-0.2.0/app/services/deps/tsconfig.py +181 -0
- srcly-0.2.0/app/services/deps/typescript_deps.py +466 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/services/python/python_analysis.py +6 -5
- {srcly-0.1.34 → srcly-0.2.0}/app/services/reporting.py +6 -5
- {srcly-0.1.34 → srcly-0.2.0}/app/services/scan_jobs.py +99 -9
- {srcly-0.1.34 → srcly-0.2.0}/app/services/scan_workers.py +38 -2
- {srcly-0.1.34 → srcly-0.2.0}/app/services/typescript/typescript_analysis.py +6 -185
- srcly-0.1.34/app/static/assets/CodeModal-BBkwbpez.js → srcly-0.2.0/app/static/assets/CodeModal-BC4-51lF.js +9 -9
- srcly-0.2.0/app/static/assets/CompareApp-CtVSUR0J.js +2 -0
- srcly-0.2.0/app/static/assets/DependencyView-Cz2blz64.js +1 -0
- srcly-0.2.0/app/static/assets/index-F49dMNqG.css +1 -0
- srcly-0.2.0/app/static/assets/index-mm3bqu0K.js +2 -0
- srcly-0.2.0/app/static/assets/layout-CCJmnknz.js +1 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/index.html +2 -2
- {srcly-0.1.34 → srcly-0.2.0}/pyproject.toml +3 -3
- srcly-0.1.34/app/config.py +0 -74
- srcly-0.1.34/app/models.py +0 -76
- srcly-0.1.34/app/routers/analysis.py +0 -607
- srcly-0.1.34/app/services/cache.py +0 -19
- srcly-0.1.34/app/static/assets/DependencyGraph-9gIcsNut.js +0 -1
- srcly-0.1.34/app/static/assets/dependencyForce.worker-WVMkyynK.js +0 -1
- srcly-0.1.34/app/static/assets/elk-worker.min-DGCccKuH.js +0 -6386
- srcly-0.1.34/app/static/assets/index-64jBfyHS.css +0 -1
- srcly-0.1.34/app/static/assets/index-CPpALji7.js +0 -2
- {srcly-0.1.34 → srcly-0.2.0}/.gitignore +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/.gitignore +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/__init__.py +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/services/css/css_analysis.py +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/services/ipynb/ipynb_analysis.py +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/services/markdown/markdown_analysis.py +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/services/scan_progress.py +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/bash-DgJyDXW6.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/css-PZBBrkI0.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/github-light-Rcb2q3L5.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/highlight.worker-C64dOQ1-.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/html-DOf0XnR2.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/javascript-WyGyM1wV.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/json-B8JryHcM.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/jsx-8cLv2Six.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/markdown-DX-TNHnc.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/python-B6Ihcp-E.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/toml-Ms459DBw.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/tsx-D6116Ycy.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/typescript-BqvgUolh.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/wasm-CG6Dc4jp.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/assets/yaml-Bopit2L3.js +0 -0
- {srcly-0.1.34 → srcly-0.2.0}/app/static/favicon.svg +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: srcly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Interactive codebase treemap and metrics viewer.
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Requires-Dist: fastapi>=0.121.3
|
|
7
7
|
Requires-Dist: httpx>=0.28.1
|
|
8
|
-
Requires-Dist: lizard>=1.
|
|
8
|
+
Requires-Dist: lizard>=1.21.7
|
|
9
9
|
Requires-Dist: pathspec>=0.12.1
|
|
10
10
|
Requires-Dist: pydantic>=2.12.4
|
|
11
11
|
Requires-Dist: pytest>=9.0.1
|
|
@@ -14,7 +14,7 @@ Requires-Dist: tree-sitter-markdown<0.6.0,>=0.5.1
|
|
|
14
14
|
Requires-Dist: tree-sitter-python==0.25.0
|
|
15
15
|
Requires-Dist: tree-sitter-scss==1.0.0
|
|
16
16
|
Requires-Dist: tree-sitter-typescript>=0.23.2
|
|
17
|
-
Requires-Dist: tree-sitter
|
|
17
|
+
Requires-Dist: tree-sitter<0.26,>=0.25.2
|
|
18
18
|
Requires-Dist: uvicorn>=0.38.0
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
20
20
|
|
|
@@ -25,7 +25,7 @@ A FastAPI server for static code analysis using [lizard](https://github.com/terr
|
|
|
25
25
|
## Features
|
|
26
26
|
|
|
27
27
|
- **Static Analysis**: Scans the codebase for cyclomatic complexity and LOC (Lines of Code).
|
|
28
|
-
- **Caching**: Caches analysis results
|
|
28
|
+
- **Caching**: Caches analysis results in the user cache directory (see the main README) and reuses them until files change.
|
|
29
29
|
- **File Serving**: Provides an API to read raw file contents (absolute paths).
|
|
30
30
|
- **API Documentation**: Auto-generated Swagger UI at `/docs`.
|
|
31
31
|
|
|
@@ -53,8 +53,8 @@ The API will be available at `http://localhost:8000`.
|
|
|
53
53
|
|
|
54
54
|
### Analysis
|
|
55
55
|
|
|
56
|
-
- `GET /api/analysis`: Get the analysis tree. Scans
|
|
57
|
-
- `POST /api/analysis/refresh`: Force a new scan.
|
|
56
|
+
- `GET /api/analysis`: Get the analysis tree. Scans unless an unchanged cached analysis exists.
|
|
57
|
+
- `POST /api/analysis/refresh`: Force a new scan and replace the cached analysis.
|
|
58
58
|
|
|
59
59
|
### Files
|
|
60
60
|
|
|
@@ -5,7 +5,7 @@ A FastAPI server for static code analysis using [lizard](https://github.com/terr
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Static Analysis**: Scans the codebase for cyclomatic complexity and LOC (Lines of Code).
|
|
8
|
-
- **Caching**: Caches analysis results
|
|
8
|
+
- **Caching**: Caches analysis results in the user cache directory (see the main README) and reuses them until files change.
|
|
9
9
|
- **File Serving**: Provides an API to read raw file contents (absolute paths).
|
|
10
10
|
- **API Documentation**: Auto-generated Swagger UI at `/docs`.
|
|
11
11
|
|
|
@@ -33,8 +33,8 @@ The API will be available at `http://localhost:8000`.
|
|
|
33
33
|
|
|
34
34
|
### Analysis
|
|
35
35
|
|
|
36
|
-
- `GET /api/analysis`: Get the analysis tree. Scans
|
|
37
|
-
- `POST /api/analysis/refresh`: Force a new scan.
|
|
36
|
+
- `GET /api/analysis`: Get the analysis tree. Scans unless an unchanged cached analysis exists.
|
|
37
|
+
- `POST /api/analysis/refresh`: Force a new scan and replace the cached analysis.
|
|
38
38
|
|
|
39
39
|
### Files
|
|
40
40
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from typing import Set
|
|
2
|
+
|
|
3
|
+
IGNORE_DIRS: Set[str] = {
|
|
4
|
+
".git",
|
|
5
|
+
"node_modules",
|
|
6
|
+
"venv",
|
|
7
|
+
".venv",
|
|
8
|
+
"__pycache__",
|
|
9
|
+
"dist",
|
|
10
|
+
"build",
|
|
11
|
+
".next",
|
|
12
|
+
"coverage",
|
|
13
|
+
".idea",
|
|
14
|
+
".vscode",
|
|
15
|
+
"target",
|
|
16
|
+
"out",
|
|
17
|
+
"android",
|
|
18
|
+
"ios",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
IGNORE_FILES: Set[str] = {
|
|
22
|
+
# In git worktrees and submodules `.git` is a pointer file, not a directory.
|
|
23
|
+
".git",
|
|
24
|
+
".DS_Store",
|
|
25
|
+
"package-lock.json",
|
|
26
|
+
"yarn.lock",
|
|
27
|
+
"pnpm-lock.yaml",
|
|
28
|
+
"cargo.lock",
|
|
29
|
+
"poetry.lock",
|
|
30
|
+
"Gemfile.lock",
|
|
31
|
+
"composer.lock",
|
|
32
|
+
"mix.lock",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
IGNORE_EXTENSIONS: Set[str] = {
|
|
36
|
+
".png",
|
|
37
|
+
".jpg",
|
|
38
|
+
".jpeg",
|
|
39
|
+
".gif",
|
|
40
|
+
".svg",
|
|
41
|
+
".ico",
|
|
42
|
+
".pdf",
|
|
43
|
+
".zip",
|
|
44
|
+
".tar",
|
|
45
|
+
".gz",
|
|
46
|
+
".map",
|
|
47
|
+
".min.js",
|
|
48
|
+
".min.css",
|
|
49
|
+
".json",
|
|
50
|
+
".xml",
|
|
51
|
+
".txt",
|
|
52
|
+
".ttf",
|
|
53
|
+
".woff",
|
|
54
|
+
".woff2",
|
|
55
|
+
".eot",
|
|
56
|
+
".otf",
|
|
57
|
+
".mp4",
|
|
58
|
+
".mp3",
|
|
59
|
+
".wav",
|
|
60
|
+
".webm",
|
|
61
|
+
".ogg",
|
|
62
|
+
".exe",
|
|
63
|
+
".dll",
|
|
64
|
+
".so",
|
|
65
|
+
".dylib",
|
|
66
|
+
".bin",
|
|
67
|
+
".iso",
|
|
68
|
+
".dmg",
|
|
69
|
+
".pkg",
|
|
70
|
+
".deb",
|
|
71
|
+
".rpm",
|
|
72
|
+
".csv",
|
|
73
|
+
".lock",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Binary formats that are never worth analyzing. Files without a listed
|
|
77
|
+
# extension are still caught by a null-byte sniff (see BINARY_SNIFF_BYTES).
|
|
78
|
+
BINARY_EXTENSIONS: Set[str] = {
|
|
79
|
+
".wasm",
|
|
80
|
+
".o",
|
|
81
|
+
".a",
|
|
82
|
+
".lib",
|
|
83
|
+
".obj",
|
|
84
|
+
".pdb",
|
|
85
|
+
".class",
|
|
86
|
+
".jar",
|
|
87
|
+
".war",
|
|
88
|
+
".pyc",
|
|
89
|
+
".pyo",
|
|
90
|
+
".node",
|
|
91
|
+
".webp",
|
|
92
|
+
".avif",
|
|
93
|
+
".bmp",
|
|
94
|
+
".tif",
|
|
95
|
+
".tiff",
|
|
96
|
+
".psd",
|
|
97
|
+
".heic",
|
|
98
|
+
".icns",
|
|
99
|
+
".mov",
|
|
100
|
+
".avi",
|
|
101
|
+
".mkv",
|
|
102
|
+
".flac",
|
|
103
|
+
".m4a",
|
|
104
|
+
".aac",
|
|
105
|
+
".7z",
|
|
106
|
+
".bz2",
|
|
107
|
+
".xz",
|
|
108
|
+
".zst",
|
|
109
|
+
".rar",
|
|
110
|
+
".tgz",
|
|
111
|
+
".sqlite",
|
|
112
|
+
".sqlite3",
|
|
113
|
+
".db",
|
|
114
|
+
".npy",
|
|
115
|
+
".npz",
|
|
116
|
+
".pkl",
|
|
117
|
+
".parquet",
|
|
118
|
+
".onnx",
|
|
119
|
+
".pt",
|
|
120
|
+
".safetensors",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# How much of each candidate file to read when sniffing for binary content.
|
|
124
|
+
BINARY_SNIFF_BYTES = 8 * 1024
|
|
125
|
+
|
|
126
|
+
# Minified/bundled JS and CSS: checked by content, since built bundles rarely
|
|
127
|
+
# carry a ``.min`` suffix (e.g. Vite's ``assets/index-<hash>.js``).
|
|
128
|
+
MINIFIED_CHECK_EXTENSIONS: Set[str] = {".js", ".mjs", ".cjs", ".css"}
|
|
129
|
+
MINIFIED_SNIFF_BYTES = 64 * 1024
|
|
130
|
+
# Hand-written code averages well under 80 characters per line.
|
|
131
|
+
MINIFIED_MIN_AVG_LINE_LENGTH = 200
|
|
132
|
+
|
|
133
|
+
# Files larger than this skip their analyzer and stay in the tree with a line
|
|
134
|
+
# count and size only (analysis_status "too_large"). The scan checks this before
|
|
135
|
+
# dispatch, so no worker is spent on them. Peak worker memory grows about 80 MB
|
|
136
|
+
# per MB of input on the heaviest paths (tree-sitter on generated TSX, CSS,
|
|
137
|
+
# lizard on HTML with data URLs), so 2 MiB keeps a worker near 220 MB. The
|
|
138
|
+
# largest real source files in the repos measured are under 1 MB.
|
|
139
|
+
MAX_ANALYZED_FILE_BYTES = 2 * 1024 * 1024
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import signal
|
|
9
9
|
import threading
|
|
10
10
|
|
|
11
|
-
from app.routers import analysis, files, scans
|
|
11
|
+
from app.routers import analysis, compare, files, scans
|
|
12
12
|
from app.services.scan_workers import cancel_all_scans
|
|
13
13
|
|
|
14
14
|
_SHUTDOWN_SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
@@ -76,6 +76,14 @@ app.add_middleware(
|
|
|
76
76
|
app.include_router(analysis.router)
|
|
77
77
|
app.include_router(files.router)
|
|
78
78
|
app.include_router(scans.router)
|
|
79
|
+
app.include_router(compare.router)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.get("/api-status")
|
|
83
|
+
async def root():
|
|
84
|
+
return {
|
|
85
|
+
"message": "Srcly server is running. Visit /docs for API documentation."
|
|
86
|
+
}
|
|
79
87
|
|
|
80
88
|
|
|
81
89
|
def _find_static_dir() -> str | None:
|
|
@@ -111,14 +119,9 @@ def _find_static_dir() -> str | None:
|
|
|
111
119
|
return None
|
|
112
120
|
|
|
113
121
|
|
|
114
|
-
# Serve SPA if we can find built assets
|
|
122
|
+
# Serve SPA if we can find built assets. Mounted last: a mount at "/" matches
|
|
123
|
+
# every path, so routes declared after it would be unreachable.
|
|
115
124
|
static_dir = _find_static_dir()
|
|
116
125
|
if static_dir:
|
|
117
126
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
@app.get("/api-status")
|
|
121
|
-
async def root():
|
|
122
|
-
return {
|
|
123
|
-
"message": "Srcly server is running. Visit /docs for API documentation."
|
|
124
|
-
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
class Metrics(BaseModel):
|
|
5
|
+
loc: int = 0
|
|
6
|
+
# Max cyclomatic complexity (CCN) of any function or nested scope in this
|
|
7
|
+
# node, so files and folders mean the same thing (a folder is the max of
|
|
8
|
+
# its children). A function node holds its own CCN.
|
|
9
|
+
complexity: float = 0.0
|
|
10
|
+
# Mean CCN per function: the analyzer's per-file average for files, and
|
|
11
|
+
# the function-count-weighted mean of children for folders.
|
|
12
|
+
complexity_avg: float = 0.0
|
|
13
|
+
function_count: int = 0
|
|
14
|
+
last_modified: float = 0.0
|
|
15
|
+
gitignored_count: int = 0
|
|
16
|
+
file_size: int = 0
|
|
17
|
+
file_count: int = 0
|
|
18
|
+
|
|
19
|
+
# New metrics
|
|
20
|
+
comment_lines: int = 0
|
|
21
|
+
comment_density: float = 0.0
|
|
22
|
+
max_nesting_depth: int = 0
|
|
23
|
+
average_function_length: float = 0.0
|
|
24
|
+
parameter_count: int = 0
|
|
25
|
+
todo_count: int = 0
|
|
26
|
+
classes_count: int = 0
|
|
27
|
+
|
|
28
|
+
# TS/TSX Specific Metrics
|
|
29
|
+
tsx_nesting_depth: int = 0
|
|
30
|
+
tsx_render_branching_count: int = 0
|
|
31
|
+
tsx_react_use_effect_count: int = 0
|
|
32
|
+
tsx_anonymous_handler_count: int = 0
|
|
33
|
+
tsx_prop_count: int = 0
|
|
34
|
+
ts_any_usage_count: int = 0
|
|
35
|
+
ts_ignore_count: int = 0
|
|
36
|
+
ts_import_coupling_count: int = 0
|
|
37
|
+
tsx_hardcoded_string_volume: int = 0
|
|
38
|
+
tsx_duplicated_string_count: int = 0
|
|
39
|
+
ts_type_interface_count: int = 0
|
|
40
|
+
ts_export_count: int = 0
|
|
41
|
+
|
|
42
|
+
# Python-specific metrics
|
|
43
|
+
python_import_count: int = 0
|
|
44
|
+
|
|
45
|
+
# Markdown-specific metrics
|
|
46
|
+
md_data_url_count: int = 0
|
|
47
|
+
|
|
48
|
+
# Analysis outcome. Files whose analyzer timed out, crashed, or raised stay
|
|
49
|
+
# in the tree with fallback LOC/size; ``analysis_status`` says why
|
|
50
|
+
# ("ok" | "timeout" | "error" | "crash" | "too_large"). "too_large" files are
|
|
51
|
+
# over ``MAX_ANALYZED_FILE_BYTES`` and were skipped on purpose, so they do
|
|
52
|
+
# not count in ``analysis_failed_count``. Folders sum failed files below.
|
|
53
|
+
analysis_status: str = "ok"
|
|
54
|
+
analysis_error: str = ""
|
|
55
|
+
analysis_failed_count: int = 0
|
|
56
|
+
|
|
57
|
+
class Node(BaseModel):
|
|
58
|
+
name: str
|
|
59
|
+
type: str # "folder", "file", "function", "code_fragment"
|
|
60
|
+
path: str
|
|
61
|
+
metrics: Metrics
|
|
62
|
+
# Use default_factory to avoid sharing the same list across instances
|
|
63
|
+
children: List["Node"] = Field(default_factory=list)
|
|
64
|
+
start_line: int = 0
|
|
65
|
+
end_line: int = 0
|
|
66
|
+
|
|
67
|
+
model_config = {
|
|
68
|
+
"populate_by_name": True
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# --- Hierarchical dependency graph contract (client/src/viz/deps/contract.ts) ---
|
|
72
|
+
|
|
73
|
+
class DepGraphNode(BaseModel):
|
|
74
|
+
"""folder | file | symbol | external node in the dependency hierarchy."""
|
|
75
|
+
|
|
76
|
+
model_config = {"populate_by_name": True}
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
kind: str
|
|
80
|
+
name: str
|
|
81
|
+
parent: str | None = None
|
|
82
|
+
path: str | None = None
|
|
83
|
+
abs_path: str | None = Field(default=None, alias="absPath")
|
|
84
|
+
language: str | None = None
|
|
85
|
+
is_test: bool | None = Field(default=None, alias="isTest")
|
|
86
|
+
symbol_kind: str | None = Field(default=None, alias="symbolKind")
|
|
87
|
+
line: int | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DepGraphEdge(BaseModel):
|
|
91
|
+
"""importer → imported; endpoints are file, symbol or external ids."""
|
|
92
|
+
|
|
93
|
+
model_config = {"populate_by_name": True}
|
|
94
|
+
|
|
95
|
+
source: str
|
|
96
|
+
target: str
|
|
97
|
+
type_only: bool | None = Field(default=None, alias="typeOnly")
|
|
98
|
+
via_barrel: bool | None = Field(default=None, alias="viaBarrel")
|
|
99
|
+
symbols: List[str] | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DepGraph(BaseModel):
|
|
103
|
+
root: str
|
|
104
|
+
nodes: List[DepGraphNode]
|
|
105
|
+
edges: List[DepGraphEdge]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Response
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from app.models import Node
|
|
5
|
+
from app.services import analysis, scan_jobs
|
|
6
|
+
from app.models import DepGraph
|
|
7
|
+
from app.services.deps.graph import build_dependency_graph
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
|
10
|
+
|
|
11
|
+
# TODO: Make this configurable via env var or request
|
|
12
|
+
ROOT_PATH = Path.cwd()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _scan_response(target_path: Path, force: bool = False) -> Response:
|
|
16
|
+
"""
|
|
17
|
+
Scan ``target_path`` through the shared job manager and return the tree.
|
|
18
|
+
|
|
19
|
+
An unchanged cached analysis is returned without scanning unless
|
|
20
|
+
``force`` is set.
|
|
21
|
+
|
|
22
|
+
Joining the manager means concurrent requests for the same path (two tabs,
|
|
23
|
+
a reload mid-scan, or a ``/api/scans`` job started by the UI) share one
|
|
24
|
+
scan instead of each starting their own. The tree is returned as
|
|
25
|
+
pre-encoded JSON so the encoding happens here, in a worker thread, rather
|
|
26
|
+
than on the event loop.
|
|
27
|
+
"""
|
|
28
|
+
job = scan_jobs.manager.start(target_path, force=force)
|
|
29
|
+
job.wait()
|
|
30
|
+
if job.phase == "cancelled":
|
|
31
|
+
raise HTTPException(status_code=503, detail="Scan cancelled")
|
|
32
|
+
if job.phase != "complete":
|
|
33
|
+
raise HTTPException(status_code=500, detail=job.error or f"Scan {job.phase}")
|
|
34
|
+
return Response(content=job.result_json(), media_type="application/json")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Scan endpoints are plain `def` on purpose: FastAPI runs them in a worker
|
|
38
|
+
# thread, so a long scan never blocks the event loop (which would also stop
|
|
39
|
+
# uvicorn from reacting to Ctrl+C). They return a `Response` directly, so
|
|
40
|
+
# `response_model` only documents the shape.
|
|
41
|
+
@router.get("", response_model=Node)
|
|
42
|
+
def get_analysis(path: str = None):
|
|
43
|
+
"""
|
|
44
|
+
Get the static analysis of the codebase.
|
|
45
|
+
Returns the cached result if no files changed since it was taken,
|
|
46
|
+
otherwise scans.
|
|
47
|
+
"""
|
|
48
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
49
|
+
|
|
50
|
+
if not target_path.exists():
|
|
51
|
+
# Fallback or error? Let's error if explicit path is invalid
|
|
52
|
+
if path:
|
|
53
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
54
|
+
target_path = ROOT_PATH
|
|
55
|
+
|
|
56
|
+
return _scan_response(target_path)
|
|
57
|
+
|
|
58
|
+
@router.get("/dependency-graph", response_model=DepGraph, response_model_exclude_none=True)
|
|
59
|
+
def get_dependency_graph(path: str = None):
|
|
60
|
+
"""
|
|
61
|
+
Hierarchical, language-neutral dependency graph (folders → files, importer → imported
|
|
62
|
+
edges, barrels passed through). The client aggregates and lays it out.
|
|
63
|
+
"""
|
|
64
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
65
|
+
if not target_path.exists():
|
|
66
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
67
|
+
if target_path.is_file():
|
|
68
|
+
target_path = target_path.parent
|
|
69
|
+
return build_dependency_graph(target_path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/refresh", response_model=Node)
|
|
73
|
+
def refresh_analysis(path: str = None):
|
|
74
|
+
"""
|
|
75
|
+
Force a re-scan of the codebase, ignoring (and replacing) the cache.
|
|
76
|
+
"""
|
|
77
|
+
target_path = Path(path) if path else ROOT_PATH
|
|
78
|
+
if not target_path.exists():
|
|
79
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
80
|
+
return _scan_response(target_path, force=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _estimate_counts(root: Path) -> tuple[int, int]:
|
|
84
|
+
"""
|
|
85
|
+
Return the (file_count, folder_count) a scan of ``root`` will analyze.
|
|
86
|
+
|
|
87
|
+
Uses the scan's own discovery (git ls-files or the .gitignore walk, plus
|
|
88
|
+
the built-in, binary, and minified filters) so the landing page matches
|
|
89
|
+
the scan.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
discovery = analysis.discover_files(root)
|
|
93
|
+
except Exception:
|
|
94
|
+
# If anything goes wrong, fall back to zeros but still report the path.
|
|
95
|
+
return 0, 0
|
|
96
|
+
return len(discovery.files), discovery.folder_count
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/context")
|
|
100
|
+
def get_analysis_context():
|
|
101
|
+
"""
|
|
102
|
+
Return basic information about the current analysis root directory.
|
|
103
|
+
|
|
104
|
+
This is used by the client to offer one-click analysis options on first
|
|
105
|
+
load, for both the current working directory and the repository root.
|
|
106
|
+
"""
|
|
107
|
+
current_root = ROOT_PATH
|
|
108
|
+
# Use the same repository root detection logic as the analysis layer so that
|
|
109
|
+
# the client sees a consistent "repo root" regardless of how the server was
|
|
110
|
+
# started (uvx, direct CLI, dev mode, etc.).
|
|
111
|
+
#
|
|
112
|
+
# This mirrors the behaviour of the CLI helper that walks upwards from the
|
|
113
|
+
# current directory to the enclosing `.git` root, falling back to the
|
|
114
|
+
# starting directory when no repository is found.
|
|
115
|
+
repo_root = analysis.find_repo_root(current_root)
|
|
116
|
+
|
|
117
|
+
current_file_count, current_folder_count = _estimate_counts(current_root)
|
|
118
|
+
|
|
119
|
+
repo_file_count = 0
|
|
120
|
+
repo_folder_count = 0
|
|
121
|
+
if repo_root.exists():
|
|
122
|
+
repo_file_count, repo_folder_count = _estimate_counts(repo_root)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"root_path": str(current_root),
|
|
126
|
+
"file_count": current_file_count,
|
|
127
|
+
"folder_count": current_folder_count,
|
|
128
|
+
"repo_root_path": str(repo_root),
|
|
129
|
+
"repo_file_count": repo_file_count,
|
|
130
|
+
"repo_folder_count": repo_folder_count,
|
|
131
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compare two git refs (PR compare mode).
|
|
3
|
+
|
|
4
|
+
GET /api/compare/refs branches + recent commits for the picker
|
|
5
|
+
POST /api/compare start (or join) a compare -> snapshot
|
|
6
|
+
GET /api/compare/{id} current snapshot (poll)
|
|
7
|
+
GET /api/compare/{id}/result compare payload once complete
|
|
8
|
+
DELETE /api/compare/{id} cancel -> snapshot
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, HTTPException
|
|
17
|
+
from fastapi.responses import Response
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from app.routers import analysis as analysis_router
|
|
21
|
+
from app.services.compare import jobs
|
|
22
|
+
from app.services.compare.git_refs import GitError, default_branch, repo_root, run_git
|
|
23
|
+
from app.services.compare.service import DEFAULT_BASE, DEFAULT_HEAD
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
|
26
|
+
|
|
27
|
+
RECENT_COMMITS = 30
|
|
28
|
+
RECENT_BRANCHES = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StartCompareRequest(BaseModel):
|
|
32
|
+
path: Optional[str] = None
|
|
33
|
+
base: str = DEFAULT_BASE
|
|
34
|
+
head: str = DEFAULT_HEAD
|
|
35
|
+
force: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _target(path: Optional[str]) -> Path:
|
|
39
|
+
target = Path(path) if path else analysis_router.ROOT_PATH
|
|
40
|
+
if not target.exists():
|
|
41
|
+
raise HTTPException(status_code=404, detail=f"Path not found: {target}")
|
|
42
|
+
return target.resolve()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _job_or_404(job_id: str) -> jobs.CompareJob:
|
|
46
|
+
job = jobs.manager.get(job_id)
|
|
47
|
+
if job is None:
|
|
48
|
+
raise HTTPException(status_code=404, detail="Compare not found")
|
|
49
|
+
return job
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/refs")
|
|
53
|
+
def list_refs(path: Optional[str] = None) -> dict:
|
|
54
|
+
try:
|
|
55
|
+
repo = repo_root(_target(path))
|
|
56
|
+
current = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
57
|
+
branches = [
|
|
58
|
+
dict(zip(("name", "sha", "date"), line.split("\0")))
|
|
59
|
+
for line in run_git(
|
|
60
|
+
repo,
|
|
61
|
+
"for-each-ref",
|
|
62
|
+
"--sort=-committerdate",
|
|
63
|
+
f"--count={RECENT_BRANCHES}",
|
|
64
|
+
"--format=%(refname:short)%00%(objectname:short)%00%(committerdate:relative)",
|
|
65
|
+
"refs/heads",
|
|
66
|
+
).splitlines()
|
|
67
|
+
]
|
|
68
|
+
commits = [
|
|
69
|
+
dict(zip(("sha", "subject", "date"), line.split("\0")))
|
|
70
|
+
for line in run_git(
|
|
71
|
+
repo, "log", f"-{RECENT_COMMITS}", "--format=%h%x00%s%x00%cr"
|
|
72
|
+
).splitlines()
|
|
73
|
+
]
|
|
74
|
+
try:
|
|
75
|
+
default = default_branch(repo)
|
|
76
|
+
except GitError:
|
|
77
|
+
default = None
|
|
78
|
+
except GitError as exc:
|
|
79
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
80
|
+
return {
|
|
81
|
+
"repo": str(repo),
|
|
82
|
+
"current": current,
|
|
83
|
+
"defaultBranch": default,
|
|
84
|
+
"branches": branches,
|
|
85
|
+
"commits": commits,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.post("", status_code=202)
|
|
90
|
+
def start_compare(req: StartCompareRequest) -> dict:
|
|
91
|
+
target = _target(req.path)
|
|
92
|
+
return jobs.manager.start(target, req.base.strip(), req.head.strip(), force=req.force).snapshot()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/{job_id}")
|
|
96
|
+
def get_compare(job_id: str) -> dict:
|
|
97
|
+
return _job_or_404(job_id).snapshot()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.delete("/{job_id}")
|
|
101
|
+
def cancel_compare(job_id: str) -> dict:
|
|
102
|
+
job = _job_or_404(job_id)
|
|
103
|
+
job.cancel_token.cancel()
|
|
104
|
+
return job.snapshot()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/{job_id}/result")
|
|
108
|
+
def get_compare_result(job_id: str):
|
|
109
|
+
job = _job_or_404(job_id)
|
|
110
|
+
if job.phase != "complete" or job.result_json is None:
|
|
111
|
+
raise HTTPException(status_code=409, detail=f"Compare is {job.phase}, not complete")
|
|
112
|
+
return Response(content=job.result_json, media_type="application/json")
|
|
@@ -7,12 +7,20 @@ from app.services.analysis import get_ipynb_analyzer
|
|
|
7
7
|
|
|
8
8
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
|
9
9
|
|
|
10
|
+
# Plain `def` endpoints: they do blocking file I/O (and notebook parsing), so
|
|
11
|
+
# FastAPI runs them in a worker thread instead of on the event loop.
|
|
12
|
+
|
|
10
13
|
@router.get("/content", response_class=PlainTextResponse)
|
|
11
|
-
|
|
14
|
+
def get_file_content(
|
|
15
|
+
path: str = Query(..., description="Absolute path to the file"),
|
|
16
|
+
ref: str | None = Query(None, description="Git commit to read the file from instead of the disk"),
|
|
17
|
+
):
|
|
12
18
|
"""
|
|
13
|
-
Get the raw content of a file.
|
|
19
|
+
Get the raw content of a file, from disk or (with ``ref``) from a git commit.
|
|
14
20
|
"""
|
|
15
21
|
file_path = Path(path)
|
|
22
|
+
if ref:
|
|
23
|
+
return _content_at_ref(file_path, ref)
|
|
16
24
|
|
|
17
25
|
if not file_path.exists():
|
|
18
26
|
raise HTTPException(status_code=404, detail="File not found")
|
|
@@ -51,8 +59,23 @@ async def get_file_content(path: str = Query(..., description="Absolute path to
|
|
|
51
59
|
except Exception as e:
|
|
52
60
|
raise HTTPException(status_code=500, detail=f"Failed to read file: {str(e)}")
|
|
53
61
|
|
|
62
|
+
def _content_at_ref(file_path: Path, ref: str) -> str:
|
|
63
|
+
"""``git show <ref>:<path>``; the file may no longer exist on disk."""
|
|
64
|
+
from app.services.compare.git_refs import GitError, repo_root, run_git
|
|
65
|
+
|
|
66
|
+
existing = file_path
|
|
67
|
+
while not existing.exists() and existing != existing.parent:
|
|
68
|
+
existing = existing.parent
|
|
69
|
+
try:
|
|
70
|
+
repo = repo_root(existing)
|
|
71
|
+
rel = file_path.resolve().relative_to(repo).as_posix()
|
|
72
|
+
return run_git(repo, "show", f"{ref}:{rel}")
|
|
73
|
+
except (GitError, ValueError) as exc:
|
|
74
|
+
raise HTTPException(status_code=404, detail=f"File not found at {ref}: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
|
|
54
77
|
@router.get("/suggest")
|
|
55
|
-
|
|
78
|
+
def suggest_files(path: str = Query(..., description="Path to list contents of")):
|
|
56
79
|
"""
|
|
57
80
|
List files and directories in the given path for auto-suggestion.
|
|
58
81
|
"""
|
|
@@ -25,12 +25,13 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Optional
|
|
26
26
|
|
|
27
27
|
from fastapi import APIRouter, HTTPException, Request
|
|
28
|
-
from fastapi.responses import StreamingResponse
|
|
28
|
+
from fastapi.responses import Response, StreamingResponse
|
|
29
29
|
from pydantic import BaseModel
|
|
30
30
|
|
|
31
31
|
from app.models import Node
|
|
32
32
|
from app.routers import analysis as analysis_router
|
|
33
|
-
from app.services
|
|
33
|
+
from app.services import scan_jobs
|
|
34
|
+
from app.services.scan_jobs import TERMINAL_PHASES, ScanJob
|
|
34
35
|
|
|
35
36
|
router = APIRouter(prefix="/api/scans", tags=["scans"])
|
|
36
37
|
|
|
@@ -41,10 +42,12 @@ CLIENT_RETRY_MS = 2000
|
|
|
41
42
|
|
|
42
43
|
class StartScanRequest(BaseModel):
|
|
43
44
|
path: Optional[str] = None
|
|
45
|
+
# Skip the analysis cache and rescan even if nothing changed.
|
|
46
|
+
force: bool = False
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
def _job_or_404(job_id: str) -> ScanJob:
|
|
47
|
-
job = manager.get(job_id)
|
|
50
|
+
job = scan_jobs.manager.get(job_id)
|
|
48
51
|
if job is None:
|
|
49
52
|
raise HTTPException(status_code=404, detail="Scan not found")
|
|
50
53
|
return job
|
|
@@ -63,7 +66,7 @@ def start_scan(req: StartScanRequest) -> dict:
|
|
|
63
66
|
target = Path(req.path) if req.path else analysis_router.ROOT_PATH
|
|
64
67
|
if not target.exists():
|
|
65
68
|
raise HTTPException(status_code=404, detail=f"Path not found: {target}")
|
|
66
|
-
return manager.start(target).snapshot()
|
|
69
|
+
return scan_jobs.manager.start(target, force=req.force).snapshot()
|
|
67
70
|
|
|
68
71
|
|
|
69
72
|
@router.get("/{job_id}")
|
|
@@ -81,9 +84,11 @@ def cancel_scan(job_id: str) -> dict:
|
|
|
81
84
|
@router.get("/{job_id}/result", response_model=Node)
|
|
82
85
|
def get_scan_result(job_id: str):
|
|
83
86
|
job = _job_or_404(job_id)
|
|
84
|
-
if job.phase != "complete"
|
|
87
|
+
if job.phase != "complete":
|
|
85
88
|
raise HTTPException(status_code=409, detail=f"Scan is {job.phase}, not complete")
|
|
86
|
-
|
|
89
|
+
# Pre-encoded bytes: serializing a large tree on the event loop would
|
|
90
|
+
# block every other request (see ScanJob.result_json).
|
|
91
|
+
return Response(content=job.result_json(), media_type="application/json")
|
|
87
92
|
|
|
88
93
|
|
|
89
94
|
@router.get("/{job_id}/events")
|