lambda-watcher 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 (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """Watch a downloads folder for AWS Lambda deployment packages and version them."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ from lambda_watcher.cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ main()
@@ -0,0 +1,115 @@
1
+ """Analysis pipeline: turn an extracted package into a structured manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..config import AnalysisConfig
10
+ from .deps import Dependency, detect_dependencies
11
+ from .envvars import EnvVarRef, detect_env_vars
12
+ from .handler import HandlerCandidate, detect_handlers
13
+ from .inventory import FileEntry, Inventory, build_inventory
14
+ from .runtime import RuntimeGuess, detect_runtime
15
+ from .secrets import Finding, scan
16
+ from .services import ServiceRef, detect_services
17
+
18
+ MANIFEST_SCHEMA = 1
19
+
20
+ __all__ = [
21
+ "Analysis",
22
+ "Dependency",
23
+ "EnvVarRef",
24
+ "FileEntry",
25
+ "Finding",
26
+ "HandlerCandidate",
27
+ "Inventory",
28
+ "RuntimeGuess",
29
+ "ServiceRef",
30
+ "analyse",
31
+ "MANIFEST_SCHEMA",
32
+ ]
33
+
34
+
35
+ @dataclass
36
+ class Analysis:
37
+ """Everything we learned about one extracted deployment package."""
38
+
39
+ inventory: Inventory
40
+ runtime: RuntimeGuess
41
+ handlers: list[HandlerCandidate] = field(default_factory=list)
42
+ dependencies: list[Dependency] = field(default_factory=list)
43
+ env_vars: list[EnvVarRef] = field(default_factory=list)
44
+ services: list[ServiceRef] = field(default_factory=list)
45
+ findings: list[Finding] = field(default_factory=list)
46
+
47
+ @property
48
+ def primary_handler(self) -> str | None:
49
+ return self.handlers[0].handler if self.handlers else None
50
+
51
+ @property
52
+ def vendor_file_count(self) -> int:
53
+ return sum(1 for f in self.inventory.files if f.is_vendor)
54
+
55
+ @property
56
+ def vendor_size(self) -> int:
57
+ return sum(f.size for f in self.inventory.files if f.is_vendor)
58
+
59
+ def unique_env_vars(self, include_reserved: bool = False) -> list[str]:
60
+ names = {
61
+ ref.name for ref in self.env_vars if include_reserved or not ref.is_reserved
62
+ }
63
+ return sorted(names)
64
+
65
+ def unique_services(self) -> list[str]:
66
+ return sorted({ref.service for ref in self.services})
67
+
68
+ def totals(self) -> dict[str, int]:
69
+ return {
70
+ "file_count": self.inventory.file_count,
71
+ "total_size": self.inventory.total_size,
72
+ "code_file_count": self.inventory.code_file_count,
73
+ "code_size": self.inventory.code_size,
74
+ "code_lines": self.inventory.code_lines,
75
+ "vendor_file_count": self.vendor_file_count,
76
+ "vendor_size": self.vendor_size,
77
+ }
78
+
79
+ def to_manifest(self, extra: dict[str, Any] | None = None) -> dict[str, Any]:
80
+ manifest: dict[str, Any] = {
81
+ "schema": MANIFEST_SCHEMA,
82
+ "tree_hash": self.inventory.tree_hash,
83
+ "runtime": self.runtime.as_dict(),
84
+ "handlers": [h.as_dict() for h in self.handlers],
85
+ "totals": self.totals(),
86
+ "languages": self.inventory.language_breakdown(),
87
+ "dependencies": [d.as_dict() for d in self.dependencies],
88
+ "env_vars": [e.as_dict() for e in self.env_vars],
89
+ "services": [s.as_dict() for s in self.services],
90
+ "findings": [f.as_dict() for f in self.findings],
91
+ "files": [f.as_dict() for f in self.inventory.files],
92
+ }
93
+ if extra:
94
+ manifest.update(extra)
95
+ return manifest
96
+
97
+
98
+ def analyse(root: Path, cfg: AnalysisConfig) -> Analysis:
99
+ """Run every analyser over the extracted tree at ``root``."""
100
+ inventory = build_inventory(root, cfg.vendor_globs, cfg.max_scan_file_kb)
101
+ runtime = detect_runtime(inventory)
102
+ handlers = detect_handlers(root, inventory)
103
+ dependencies = detect_dependencies(root, inventory)
104
+ env_vars = detect_env_vars(root, inventory) if cfg.scan_env_vars else []
105
+ services = detect_services(root, inventory) if cfg.scan_aws_services else []
106
+ findings = scan(root, inventory, check_secrets=cfg.scan_secrets)
107
+ return Analysis(
108
+ inventory=inventory,
109
+ runtime=runtime,
110
+ handlers=handlers,
111
+ dependencies=dependencies,
112
+ env_vars=env_vars,
113
+ services=services,
114
+ findings=findings,
115
+ )
@@ -0,0 +1,291 @@
1
+ """Extract dependencies from a deployment package.
2
+
3
+ Two kinds of dependency are collected and kept apart:
4
+
5
+ ``declared``
6
+ What a manifest says the function wants (``requirements.txt``,
7
+ ``package.json``, ``go.mod`` ...).
8
+ ``installed``
9
+ What is actually vendored inside the zip (``*.dist-info/METADATA``,
10
+ ``node_modules/*/package.json``). These are the versions that really ran,
11
+ and they are usually the ones worth diffing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass
19
+ from pathlib import Path, PurePosixPath
20
+
21
+ from ..utils import LOG, read_text
22
+ from .inventory import Inventory
23
+
24
+ try: # Python 3.11+
25
+ import tomllib # type: ignore[import-not-found]
26
+ except ModuleNotFoundError: # pragma: no cover
27
+ try:
28
+ import tomli as tomllib # type: ignore[no-redef]
29
+ except ModuleNotFoundError: # pragma: no cover
30
+ tomllib = None # type: ignore[assignment]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Dependency:
35
+ manager: str # pip | npm | go | maven | gem
36
+ name: str
37
+ version: str | None
38
+ source: str # the file it came from
39
+ is_declared: bool # False => vendored/installed
40
+
41
+ def key(self) -> tuple[str, str]:
42
+ return (self.manager, self.name.lower())
43
+
44
+ def as_dict(self) -> dict:
45
+ return {
46
+ "manager": self.manager,
47
+ "name": self.name,
48
+ "version": self.version,
49
+ "source": self.source,
50
+ "is_declared": self.is_declared,
51
+ }
52
+
53
+
54
+ _REQ_LINE = re.compile(
55
+ r"^\s*(?P<name>[A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*"
56
+ r"(?P<op>==|>=|<=|~=|!=|>|<|===)?\s*(?P<version>[^\s;#]+)?"
57
+ )
58
+
59
+
60
+ def _parse_requirements(text: str, source: str) -> list[Dependency]:
61
+ deps: list[Dependency] = []
62
+ for raw in text.splitlines():
63
+ line = raw.split("#", 1)[0].strip()
64
+ if not line or line.startswith("-"):
65
+ continue # -r includes, -e editables, --index-url
66
+ if line.startswith(("git+", "http://", "https://")):
67
+ name = re.sub(r"[#?].*$", "", line).rstrip("/").split("/")[-1]
68
+ deps.append(Dependency("pip", name or line, None, source, True))
69
+ continue
70
+ if " @ " in line: # PEP 508 direct reference
71
+ name = line.split(" @ ", 1)[0].strip()
72
+ deps.append(Dependency("pip", name, line.split(" @ ", 1)[1].strip(), source, True))
73
+ continue
74
+ match = _REQ_LINE.match(line)
75
+ if not match or not match.group("name"):
76
+ continue
77
+ version = match.group("version") if match.group("op") else None
78
+ deps.append(Dependency("pip", match.group("name"), version, source, True))
79
+ return deps
80
+
81
+
82
+ def _parse_pyproject(text: str, source: str) -> list[Dependency]:
83
+ if tomllib is None:
84
+ return []
85
+ try:
86
+ data = tomllib.loads(text)
87
+ except Exception: # noqa: BLE001 - malformed manifests are not fatal
88
+ return []
89
+ deps: list[Dependency] = []
90
+ for spec in data.get("project", {}).get("dependencies", []) or []:
91
+ if not isinstance(spec, str):
92
+ continue
93
+ match = _REQ_LINE.match(spec)
94
+ if match and match.group("name"):
95
+ deps.append(
96
+ Dependency(
97
+ "pip", match.group("name"),
98
+ match.group("version") if match.group("op") else None, source, True,
99
+ )
100
+ )
101
+ poetry = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) or {}
102
+ for name, spec in poetry.items():
103
+ if name.lower() == "python":
104
+ continue
105
+ version = spec if isinstance(spec, str) else (spec or {}).get("version")
106
+ deps.append(Dependency("pip", name, version, source, True))
107
+ return deps
108
+
109
+
110
+ def _parse_package_json(text: str, source: str, declared: bool = True) -> list[Dependency]:
111
+ try:
112
+ data = json.loads(text)
113
+ except json.JSONDecodeError:
114
+ return []
115
+ deps: list[Dependency] = []
116
+ if not declared:
117
+ # A vendored node_modules/<pkg>/package.json: the resolved version.
118
+ name = data.get("name")
119
+ version = data.get("version")
120
+ if isinstance(name, str):
121
+ return [Dependency("npm", name, version if isinstance(version, str) else None, source, False)]
122
+ return []
123
+ for section in ("dependencies", "devDependencies", "optionalDependencies"):
124
+ for name, version in (data.get(section) or {}).items():
125
+ deps.append(
126
+ Dependency("npm", name, version if isinstance(version, str) else None, source, True)
127
+ )
128
+ return deps
129
+
130
+
131
+ def _parse_package_lock(text: str, source: str) -> list[Dependency]:
132
+ try:
133
+ data = json.loads(text)
134
+ except json.JSONDecodeError:
135
+ return []
136
+ deps: list[Dependency] = []
137
+ packages = data.get("packages")
138
+ if isinstance(packages, dict): # lockfile v2 / v3
139
+ for path, meta in packages.items():
140
+ if not path or not isinstance(meta, dict):
141
+ continue
142
+ name = meta.get("name") or path.split("node_modules/")[-1]
143
+ version = meta.get("version")
144
+ if name:
145
+ deps.append(Dependency("npm", name, version, source, False))
146
+ elif isinstance(data.get("dependencies"), dict): # lockfile v1
147
+ for name, meta in data["dependencies"].items():
148
+ version = meta.get("version") if isinstance(meta, dict) else None
149
+ deps.append(Dependency("npm", name, version, source, False))
150
+ return deps
151
+
152
+
153
+ _YARN_ENTRY = re.compile(r'^"?([^@\s"][^@\s"]*)@[^\n:]*:\s*$\n(?:.*\n)*?\s+version\s+"([^"]+)"', re.MULTILINE)
154
+
155
+
156
+ def _parse_yarn_lock(text: str, source: str) -> list[Dependency]:
157
+ deps: list[Dependency] = []
158
+ for match in _YARN_ENTRY.finditer(text):
159
+ deps.append(Dependency("npm", match.group(1), match.group(2), source, False))
160
+ return deps
161
+
162
+
163
+ _GO_REQUIRE_BLOCK = re.compile(r"require\s*\(([^)]*)\)", re.DOTALL)
164
+ _GO_REQUIRE_LINE = re.compile(r"^\s*([^\s/]+\S*)\s+(v\S+)", re.MULTILINE)
165
+
166
+
167
+ def _parse_go_mod(text: str, source: str) -> list[Dependency]:
168
+ deps: list[Dependency] = []
169
+ for block in _GO_REQUIRE_BLOCK.findall(text):
170
+ for name, version in _GO_REQUIRE_LINE.findall(block):
171
+ deps.append(Dependency("go", name, version, source, True))
172
+ for line in text.splitlines():
173
+ line = line.strip()
174
+ if line.startswith("require ") and "(" not in line:
175
+ parts = line.split()
176
+ if len(parts) >= 3:
177
+ deps.append(Dependency("go", parts[1], parts[2], source, True))
178
+ return deps
179
+
180
+
181
+ def _parse_pom(text: str, source: str) -> list[Dependency]:
182
+ deps: list[Dependency] = []
183
+ for block in re.findall(r"<dependency>(.*?)</dependency>", text, re.DOTALL):
184
+ group = re.search(r"<groupId>(.*?)</groupId>", block, re.DOTALL)
185
+ artifact = re.search(r"<artifactId>(.*?)</artifactId>", block, re.DOTALL)
186
+ version = re.search(r"<version>(.*?)</version>", block, re.DOTALL)
187
+ if artifact:
188
+ artifact_id = artifact.group(1).strip()
189
+ name = f"{group.group(1).strip()}:{artifact_id}" if group else artifact_id
190
+ resolved = version.group(1).strip() if version else None
191
+ deps.append(Dependency("maven", name, resolved, source, True))
192
+ return deps
193
+
194
+
195
+ _GEMFILE_LOCK = re.compile(r"^\s{4}([a-zA-Z0-9_-]+)\s+\(([^)]+)\)", re.MULTILINE)
196
+
197
+
198
+ def _parse_gemfile_lock(text: str, source: str) -> list[Dependency]:
199
+ return [
200
+ Dependency("gem", name, version, source, False)
201
+ for name, version in _GEMFILE_LOCK.findall(text)
202
+ ]
203
+
204
+
205
+ def _parse_metadata(text: str, source: str) -> list[Dependency]:
206
+ """``*.dist-info/METADATA`` or ``*.egg-info/PKG-INFO``: an installed package."""
207
+ name = version = None
208
+ for line in text.splitlines():
209
+ if line.startswith("Name:") and name is None:
210
+ name = line.split(":", 1)[1].strip()
211
+ elif line.startswith("Version:") and version is None:
212
+ version = line.split(":", 1)[1].strip()
213
+ if name and version:
214
+ break
215
+ if not line.strip(): # headers end at the first blank line
216
+ break
217
+ if name:
218
+ return [Dependency("pip", name, version, source, False)]
219
+ return []
220
+
221
+
222
+ # filename (lowercase) -> parser
223
+ _MANIFEST_PARSERS = {
224
+ "requirements.txt": _parse_requirements,
225
+ "requirements-prod.txt": _parse_requirements,
226
+ "requirements_prod.txt": _parse_requirements,
227
+ "pyproject.toml": _parse_pyproject,
228
+ "package.json": _parse_package_json,
229
+ "package-lock.json": _parse_package_lock,
230
+ "yarn.lock": _parse_yarn_lock,
231
+ "go.mod": _parse_go_mod,
232
+ "pom.xml": _parse_pom,
233
+ "gemfile.lock": _parse_gemfile_lock,
234
+ }
235
+
236
+
237
+ def detect_dependencies(root: Path, inventory: Inventory) -> list[Dependency]:
238
+ """Collect declared and installed dependencies from an extracted package."""
239
+ found: list[Dependency] = []
240
+
241
+ for entry in inventory.files:
242
+ pure = PurePosixPath(entry.path)
243
+ name = pure.name.lower()
244
+ parent = pure.parent.name.lower()
245
+
246
+ try:
247
+ # Installed Python distributions.
248
+ if name == "metadata" and parent.endswith(".dist-info"):
249
+ text = read_text(root / entry.path, max_bytes=64 * 1024)
250
+ if text:
251
+ found.extend(_parse_metadata(text, entry.path))
252
+ continue
253
+ if name == "pkg-info" and parent.endswith(".egg-info"):
254
+ text = read_text(root / entry.path, max_bytes=64 * 1024)
255
+ if text:
256
+ found.extend(_parse_metadata(text, entry.path))
257
+ continue
258
+
259
+ # Installed npm packages: node_modules/<pkg>/package.json, or
260
+ # node_modules/@scope/<pkg>/package.json for scoped ones.
261
+ if name == "package.json" and "node_modules/" in entry.path:
262
+ tail = entry.path.rsplit("node_modules/", 1)[1]
263
+ max_depth = 2 if tail.startswith("@") else 1
264
+ if tail.count("/") <= max_depth: # skip foo/dist/package.json and friends
265
+ text = read_text(root / entry.path, max_bytes=256 * 1024)
266
+ if text:
267
+ found.extend(_parse_package_json(text, entry.path, declared=False))
268
+ continue
269
+
270
+ if entry.is_vendor:
271
+ continue
272
+
273
+ parser = _MANIFEST_PARSERS.get(name)
274
+ if parser is None:
275
+ continue
276
+ if entry.size > 8 * 1024 * 1024:
277
+ continue
278
+ text = read_text(root / entry.path, max_bytes=8 * 1024 * 1024)
279
+ if text:
280
+ found.extend(parser(text, entry.path))
281
+ except Exception as exc: # noqa: BLE001 - one bad manifest must not fail ingest
282
+ LOG.debug("dependency parse failed for %s: %s", entry.path, exc)
283
+
284
+ # Deduplicate, preferring installed versions over declared ranges.
285
+ best: dict[tuple[str, str, bool], Dependency] = {}
286
+ for dep in found:
287
+ key = (dep.manager, dep.name.lower(), dep.is_declared)
288
+ current = best.get(key)
289
+ if current is None or (current.version is None and dep.version is not None):
290
+ best[key] = dep
291
+ return sorted(best.values(), key=lambda d: (d.manager, d.is_declared, d.name.lower()))
@@ -0,0 +1,80 @@
1
+ """Collect the environment variables the code reads.
2
+
3
+ Environment variables are configuration that lives outside the zip, so a diff
4
+ that shows a new ``os.environ["TABLE_NAME"]`` is a strong signal that the
5
+ deployment also needs a config change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from ..utils import read_text
15
+ from .inventory import Inventory
16
+
17
+ _PATTERNS = [
18
+ re.compile(r"""os\.environ\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]"""),
19
+ re.compile(r"""os\.environ\.get\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]"""),
20
+ re.compile(r"""os\.getenv\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]"""),
21
+ re.compile(r"""process\.env\.([A-Za-z_][A-Za-z0-9_]*)"""),
22
+ re.compile(r"""process\.env\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]"""),
23
+ re.compile(r"""System\.getenv\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"""),
24
+ re.compile(r"""ENV\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]"""),
25
+ re.compile(r"""Environment\.GetEnvironmentVariable\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"""),
26
+ ]
27
+
28
+ # Runtime-provided variables are noise in a diff.
29
+ _AWS_RESERVED = {
30
+ "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EXECUTION_ENV", "AWS_LAMBDA_FUNCTION_NAME",
31
+ "AWS_LAMBDA_FUNCTION_VERSION", "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "AWS_LAMBDA_LOG_GROUP_NAME",
32
+ "AWS_LAMBDA_LOG_STREAM_NAME", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY",
33
+ "AWS_SESSION_TOKEN", "LAMBDA_TASK_ROOT", "LAMBDA_RUNTIME_DIR", "_HANDLER", "_X_AMZN_TRACE_ID",
34
+ "TZ", "PATH", "HOME", "NODE_PATH", "PYTHONPATH", "LANG",
35
+ }
36
+
37
+ _SCANNABLE = {"python", "javascript", "typescript", "java", "ruby", "csharp", "go", "shell"}
38
+
39
+
40
+ @dataclass
41
+ class EnvVarRef:
42
+ name: str
43
+ path: str
44
+ line: int
45
+ is_reserved: bool = False
46
+
47
+ def as_dict(self) -> dict:
48
+ return {"name": self.name, "path": self.path, "line": self.line, "is_reserved": self.is_reserved}
49
+
50
+
51
+ def detect_env_vars(
52
+ root: Path, inventory: Inventory, include_vendor: bool = False, max_files: int = 2000
53
+ ) -> list[EnvVarRef]:
54
+ refs: list[EnvVarRef] = []
55
+ seen: set[tuple[str, str, int]] = set()
56
+ entries = inventory.files if include_vendor else inventory.code_files
57
+ scanned = 0
58
+
59
+ for entry in entries:
60
+ if scanned >= max_files:
61
+ break
62
+ if not entry.is_text or entry.lang not in _SCANNABLE:
63
+ continue
64
+ text = read_text(root / entry.path, max_bytes=1024 * 1024)
65
+ if not text:
66
+ continue
67
+ scanned += 1
68
+ for line_no, line in enumerate(text.splitlines(), start=1):
69
+ for pattern in _PATTERNS:
70
+ for match in pattern.finditer(line):
71
+ name = match.group(1)
72
+ key = (name, entry.path, line_no)
73
+ if key in seen:
74
+ continue
75
+ seen.add(key)
76
+ refs.append(
77
+ EnvVarRef(name, entry.path, line_no, is_reserved=name in _AWS_RESERVED)
78
+ )
79
+ refs.sort(key=lambda r: (r.name, r.path, r.line))
80
+ return refs
@@ -0,0 +1,111 @@
1
+ """Find the likely Lambda entry point(s) in an extracted package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from ..utils import read_text
10
+ from .inventory import Inventory
11
+
12
+ # def handler(event, context) / async def handler(event, context)
13
+ _PY_HANDLER = re.compile(
14
+ r"^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(\s*[A-Za-z_]\w*\s*(?::[^,)]+)?\s*,"
15
+ r"\s*[A-Za-z_]\w*",
16
+ re.MULTILINE,
17
+ )
18
+ _JS_HANDLER = re.compile(
19
+ r"(?:^|\n)\s*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=|"
20
+ r"(?:^|\n)\s*export\s+(?:async\s+)?(?:const|function)\s+([A-Za-z_$][\w$]*)",
21
+ )
22
+ _JAVA_HANDLER = re.compile(r"implements\s+RequestHandler|public\s+\w+\s+handleRequest\s*\(")
23
+
24
+ # Filenames AWS itself defaults to, in preference order.
25
+ _PREFERRED = (
26
+ "lambda_function.py", "index.mjs", "index.js", "index.py", "app.py",
27
+ "main.py", "handler.py", "lambda_handler.py", "app.js", "handler.js",
28
+ "index.cjs", "main.go", "bootstrap",
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class HandlerCandidate:
34
+ path: str
35
+ symbol: str
36
+ handler: str # "module.function", the value you paste into the console
37
+ score: int
38
+
39
+ def as_dict(self) -> dict:
40
+ return {"path": self.path, "symbol": self.symbol, "handler": self.handler, "score": self.score}
41
+
42
+
43
+ def _module_name(path: str) -> str:
44
+ pure = PurePosixPath(path)
45
+ stem = pure.stem
46
+ parts = list(pure.parts[:-1]) + [stem]
47
+ return ".".join(parts)
48
+
49
+
50
+ def detect_handlers(root: Path, inventory: Inventory, max_files: int = 400) -> list[HandlerCandidate]:
51
+ """Return handler candidates, best guess first."""
52
+ candidates: list[HandlerCandidate] = []
53
+ scanned = 0
54
+
55
+ entries = [
56
+ f
57
+ for f in inventory.code_files
58
+ if f.is_text and f.lang in {"python", "javascript", "typescript", "java"}
59
+ ]
60
+ # Shallow files first: the real handler is rarely six directories deep.
61
+ entries.sort(key=lambda f: (f.path.count("/"), len(f.path)))
62
+
63
+ for entry in entries:
64
+ if scanned >= max_files:
65
+ break
66
+ scanned += 1
67
+ text = read_text(root / entry.path, max_bytes=512 * 1024)
68
+ if not text:
69
+ continue
70
+
71
+ name = PurePosixPath(entry.path).name
72
+ base_score = 0
73
+ if name in _PREFERRED:
74
+ base_score += 50 - _PREFERRED.index(name)
75
+ if entry.path.count("/") == 0:
76
+ base_score += 20
77
+
78
+ if entry.lang == "python":
79
+ for match in _PY_HANDLER.finditer(text):
80
+ symbol = match.group(1)
81
+ score = base_score + (30 if symbol in {"lambda_handler", "handler"} else 0)
82
+ if symbol.startswith("_"):
83
+ score -= 15
84
+ candidates.append(
85
+ HandlerCandidate(entry.path, symbol, f"{_module_name(entry.path)}.{symbol}", score)
86
+ )
87
+ elif entry.lang in {"javascript", "typescript"}:
88
+ for match in _JS_HANDLER.finditer(text):
89
+ symbol = match.group(1) or match.group(2)
90
+ if not symbol:
91
+ continue
92
+ score = base_score + (30 if symbol == "handler" else 0)
93
+ candidates.append(
94
+ HandlerCandidate(entry.path, symbol, f"{_module_name(entry.path)}.{symbol}", score)
95
+ )
96
+ elif entry.lang == "java":
97
+ if _JAVA_HANDLER.search(text):
98
+ candidates.append(
99
+ HandlerCandidate(entry.path, "handleRequest", _module_name(entry.path), base_score + 20)
100
+ )
101
+
102
+ candidates.sort(key=lambda c: -c.score)
103
+ # Deduplicate on the handler string while preserving order.
104
+ seen: set[str] = set()
105
+ unique: list[HandlerCandidate] = []
106
+ for candidate in candidates:
107
+ if candidate.handler in seen:
108
+ continue
109
+ seen.add(candidate.handler)
110
+ unique.append(candidate)
111
+ return unique[:10]