blitz-cli 0.1.0__tar.gz → 0.3.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.
- blitz_cli-0.3.0/.github/workflows/homebrew.yml +54 -0
- blitz_cli-0.3.0/LICENSE +21 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/PKG-INFO +3 -1
- blitz_cli-0.3.0/blitz_cli/__init__.py +26 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/_client.py +15 -2
- blitz_cli-0.3.0/blitz_cli/_scan/__init__.py +65 -0
- blitz_cli-0.3.0/blitz_cli/_scan/_classify.py +28 -0
- blitz_cli-0.3.0/blitz_cli/_scan/_js.py +98 -0
- blitz_cli-0.3.0/blitz_cli/_scan/_models.py +126 -0
- blitz_cli-0.3.0/blitz_cli/_scan/_python.py +308 -0
- blitz_cli-0.3.0/blitz_cli/_scan/_report.py +196 -0
- blitz_cli-0.3.0/blitz_cli/_scan/_walk.py +68 -0
- blitz_cli-0.3.0/blitz_cli/_skill.py +42 -0
- blitz_cli-0.3.0/blitz_cli/cli.py +383 -0
- blitz_cli-0.3.0/blitz_cli/skills/__init__.py +0 -0
- blitz_cli-0.3.0/blitz_cli/skills/onboard-to-blitz/SKILL.md +140 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/Makefile.tmpl +1 -1
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/eval.py.tmpl +21 -0
- blitz_cli-0.3.0/blitz_cli/templates/train.py.tmpl +184 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/pyproject.toml +2 -1
- blitz_cli-0.3.0/tests/test_scan.py +308 -0
- blitz_cli-0.1.0/blitz_cli/__init__.py +0 -11
- blitz_cli-0.1.0/blitz_cli/cli.py +0 -128
- blitz_cli-0.1.0/blitz_cli/templates/train.py.tmpl +0 -89
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/.gitignore +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/README.md +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/_scaffold.py +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/Dockerfile.tmpl +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/README.md.tmpl +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/__init__.py +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/dockerignore.tmpl +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/blitz_cli/templates/requirements.txt.tmpl +0 -0
- {blitz_cli-0.1.0 → blitz_cli-0.3.0}/tests/test_scaffold.py +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: Update Homebrew tap
|
|
2
|
+
|
|
3
|
+
# On every release tag, point the sparepartslabs/homebrew-tap formula at the new
|
|
4
|
+
# PyPI sdist. Runs alongside publish.yml (same v* trigger) and waits for the
|
|
5
|
+
# sdist to appear on PyPI before reading its canonical URL + sha256.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags:
|
|
9
|
+
- "v*"
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
bump:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- name: Resolve version
|
|
16
|
+
id: v
|
|
17
|
+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
|
18
|
+
|
|
19
|
+
- name: Fetch sdist url + sha256 from PyPI (wait for publish)
|
|
20
|
+
id: pypi
|
|
21
|
+
run: |
|
|
22
|
+
VERSION="${{ steps.v.outputs.version }}"
|
|
23
|
+
for i in $(seq 1 30); do
|
|
24
|
+
JSON=$(curl -fsSL "https://pypi.org/pypi/blitz-cli/${VERSION}/json" || true)
|
|
25
|
+
URL=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .url')
|
|
26
|
+
SHA=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .digests.sha256')
|
|
27
|
+
if [ -n "$URL" ] && [ "$URL" != "null" ]; then break; fi
|
|
28
|
+
echo "sdist for ${VERSION} not on PyPI yet, retrying ($i)..."
|
|
29
|
+
sleep 10
|
|
30
|
+
done
|
|
31
|
+
if [ -z "$URL" ] || [ "$URL" = "null" ]; then
|
|
32
|
+
echo "::error::blitz-cli ${VERSION} sdist not found on PyPI"; exit 1
|
|
33
|
+
fi
|
|
34
|
+
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
35
|
+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
|
36
|
+
|
|
37
|
+
- name: Checkout tap
|
|
38
|
+
uses: actions/checkout@v4
|
|
39
|
+
with:
|
|
40
|
+
repository: sparepartslabs/homebrew-tap
|
|
41
|
+
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
42
|
+
|
|
43
|
+
- name: Update formula
|
|
44
|
+
run: |
|
|
45
|
+
F=Formula/blitz-cli.rb
|
|
46
|
+
sed -i -E "s#^ url \".*\"# url \"${{ steps.pypi.outputs.url }}\"#" "$F"
|
|
47
|
+
sed -i -E "s#^ sha256 \".*\"# sha256 \"${{ steps.pypi.outputs.sha }}\"#" "$F"
|
|
48
|
+
git config user.name "github-actions[bot]"
|
|
49
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
50
|
+
if git diff --quiet; then
|
|
51
|
+
echo "formula already up to date"; exit 0
|
|
52
|
+
fi
|
|
53
|
+
git commit -am "blitz-cli ${{ steps.v.outputs.version }}"
|
|
54
|
+
git push
|
blitz_cli-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Spare Parts Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: blitz-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Developer CLI for Blitz: pull a workflow's training data + base-model recommendation and scaffold a runnable QLoRA training container.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
5
7
|
Requires-Python: >=3.9
|
|
6
8
|
Description-Content-Type: text/markdown
|
|
7
9
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""blitz-cli — onboard a codebase to Blitz and scaffold a QLoRA trainer.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
|
|
5
|
+
- ``blitz init -p <project>`` — write a repo-local ``.env`` (BLITZ_PROJECT /
|
|
6
|
+
BLITZ_ENDPOINT) and validate your ingest key.
|
|
7
|
+
|
|
8
|
+
- ``blitz scan [path]`` — statically walk a codebase and report each AI/LLM
|
|
9
|
+
call's Blitz instrumentation posture (unsupported provider / no blitz.init() /
|
|
10
|
+
traced-but-unnamed / instrumented), with copy-paste fixes. Offline, no API key.
|
|
11
|
+
|
|
12
|
+
- ``blitz verify [--wait]`` — confirm sample traces have landed in Blitz,
|
|
13
|
+
authenticated by the ingest key (``BLITZ_API_KEY``).
|
|
14
|
+
|
|
15
|
+
- ``blitz skill install`` — drop the ``onboard-to-blitz`` Claude Code skill into
|
|
16
|
+
the repo's ``.claude/skills`` so Claude can run the onboarding playbook.
|
|
17
|
+
|
|
18
|
+
- ``blitz scaffold -p <project> -w <workflow> -o ./train`` — download a
|
|
19
|
+
workflow's SFT dataset, held-out eval set, and base-model recommendation, then
|
|
20
|
+
emit a self-contained, runnable training project (Dockerfile + train.py +
|
|
21
|
+
eval.py). Authenticates with a read-scoped Blitz API key (``BLITZ_API_KEY``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__"]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.3.0"
|
|
@@ -26,8 +26,9 @@ class BlitzAPIError(RuntimeError):
|
|
|
26
26
|
def _hint(status: int, detail: str) -> str:
|
|
27
27
|
if status in (401, 403):
|
|
28
28
|
return (
|
|
29
|
-
f"{detail} (HTTP {status}). Check BLITZ_API_KEY — it must be a "
|
|
30
|
-
"
|
|
29
|
+
f"{detail} (HTTP {status}). Check BLITZ_API_KEY — it must be a valid "
|
|
30
|
+
"key for this project (an ingest key for tracing/verify, a read key "
|
|
31
|
+
"for training data). Create one in the dashboard."
|
|
31
32
|
)
|
|
32
33
|
if status == 404:
|
|
33
34
|
return (
|
|
@@ -71,6 +72,12 @@ class BlitzClient:
|
|
|
71
72
|
except Exception: # noqa: BLE001
|
|
72
73
|
pass
|
|
73
74
|
raise BlitzAPIError(exc.code, _hint(exc.code, str(detail))) from None
|
|
75
|
+
except urllib.error.URLError as exc:
|
|
76
|
+
raise BlitzAPIError(
|
|
77
|
+
0,
|
|
78
|
+
f"could not reach the Blitz API at {self._base} ({exc.reason}). "
|
|
79
|
+
"Check BLITZ_ENDPOINT and your network.",
|
|
80
|
+
) from None
|
|
74
81
|
|
|
75
82
|
def _get_json(self, path: str, query: Optional[dict] = None) -> dict:
|
|
76
83
|
req = urllib.request.Request(self._url(path, query), headers=self._headers)
|
|
@@ -136,3 +143,9 @@ class BlitzClient:
|
|
|
136
143
|
|
|
137
144
|
def get_eval_run(self, run_id: str) -> dict:
|
|
138
145
|
return self._get_json(self._p(f"/eval/runs/{run_id}"))
|
|
146
|
+
|
|
147
|
+
def ingest_status(self, workflow: Optional[str] = None) -> dict:
|
|
148
|
+
"""Trace summary for `blitz verify`, authed by the ingest key. This is
|
|
149
|
+
the one read the ingest scope can do (path is not project-scoped; the
|
|
150
|
+
key resolves the project server-side)."""
|
|
151
|
+
return self._get_json("/blitz/v1/ingest-status", {"workflow": workflow})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Codebase scan: find AI/LLM calls and report their Blitz instrumentation
|
|
2
|
+
posture. Public surface: ``scan_path``, ``render_report``, ``dumps_json``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Set
|
|
8
|
+
|
|
9
|
+
from ._classify import classify
|
|
10
|
+
from ._js import scan_js
|
|
11
|
+
from ._models import ProjectSignals, ScanResult, Tier
|
|
12
|
+
from ._python import scan_python
|
|
13
|
+
from ._report import dumps_json, render_report
|
|
14
|
+
from ._walk import iter_source_files
|
|
15
|
+
|
|
16
|
+
__all__ = ["scan_path", "render_report", "dumps_json", "ScanResult", "Tier"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def scan_path(root: Path, langs: Optional[Set[str]] = None) -> ScanResult:
|
|
20
|
+
"""Walk ``root``, detect LLM calls in each file, then classify every site
|
|
21
|
+
against tree-wide signals (whether the project calls ``blitz.init()``)."""
|
|
22
|
+
langs = langs or {"python", "js"}
|
|
23
|
+
root = root.resolve()
|
|
24
|
+
base = root if root.is_dir() else root.parent
|
|
25
|
+
|
|
26
|
+
result = ScanResult(root=str(root))
|
|
27
|
+
signals = ProjectSignals()
|
|
28
|
+
|
|
29
|
+
for path, lang in iter_source_files(root, langs):
|
|
30
|
+
try:
|
|
31
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
32
|
+
except OSError:
|
|
33
|
+
continue
|
|
34
|
+
rel = _relpath(path, base)
|
|
35
|
+
try:
|
|
36
|
+
scan = scan_python(path, source, rel) if lang == "python" else scan_js(
|
|
37
|
+
path, source, rel
|
|
38
|
+
)
|
|
39
|
+
except SyntaxError:
|
|
40
|
+
result.skipped.append(rel)
|
|
41
|
+
continue
|
|
42
|
+
signals.files_scanned += 1
|
|
43
|
+
if scan.has_init:
|
|
44
|
+
if lang == "python":
|
|
45
|
+
signals.has_python_init = True
|
|
46
|
+
else:
|
|
47
|
+
signals.has_js_init = True
|
|
48
|
+
result.sites.extend(scan.sites)
|
|
49
|
+
|
|
50
|
+
# Classification needs the tree-wide init signal, so it runs after the walk.
|
|
51
|
+
result.signals = signals
|
|
52
|
+
for site in result.sites:
|
|
53
|
+
classify(site, signals)
|
|
54
|
+
|
|
55
|
+
# Stable ordering: worst tier first, then path/line.
|
|
56
|
+
order = {Tier.UNSUPPORTED: 0, Tier.NO_INIT: 1, Tier.UNNAMED: 2, Tier.INSTRUMENTED: 3}
|
|
57
|
+
result.sites.sort(key=lambda s: (order.get(s.tier, 9), s.path, s.line))
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _relpath(path: Path, base: Path) -> str:
|
|
62
|
+
try:
|
|
63
|
+
return path.resolve().relative_to(base).as_posix()
|
|
64
|
+
except ValueError:
|
|
65
|
+
return path.as_posix()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The 4-tier decision tree. Pure and language-agnostic: both detectors funnel
|
|
2
|
+
their ``CallSite`` drafts through here once project-wide signals are known."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ._models import CallSite, Confidence, ProjectSignals, Provider, SUPPORTED, Tier
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def classify(site: CallSite, signals: ProjectSignals) -> None:
|
|
10
|
+
"""Set ``site.tier`` (and adjust confidence) in place."""
|
|
11
|
+
if site.in_untraced:
|
|
12
|
+
# Intentional suppression (blitz-platform meta-calls). Not a miss; we
|
|
13
|
+
# still label it INSTRUMENTED-equivalent so it drops out of misses().
|
|
14
|
+
site.tier = Tier.INSTRUMENTED
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
if site.provider not in SUPPORTED:
|
|
18
|
+
site.tier = Tier.UNSUPPORTED
|
|
19
|
+
elif not signals.has_init(site.lang):
|
|
20
|
+
site.tier = Tier.NO_INIT
|
|
21
|
+
elif not site.in_named_workflow:
|
|
22
|
+
site.tier = Tier.UNNAMED
|
|
23
|
+
# A decorated caller further up the stack may already be tracing this
|
|
24
|
+
# helper at runtime — we can't see that lexically, so soften confidence.
|
|
25
|
+
if site.confidence is Confidence.HIGH:
|
|
26
|
+
site.confidence = Confidence.MEDIUM
|
|
27
|
+
else:
|
|
28
|
+
site.tier = Tier.INSTRUMENTED
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""JS/TS detector — honest regex heuristic.
|
|
2
|
+
|
|
3
|
+
There's no stdlib JS parser and blitz-cli must stay dependency-free, so this is
|
|
4
|
+
line-oriented ``re`` matching, not real parsing. Consequences, stated loudly:
|
|
5
|
+
no scope resolution, no reliable client-var/decorator tracking, and — because a
|
|
6
|
+
JS Blitz SDK's ``init`` can't be verified here — supported-provider JS calls are
|
|
7
|
+
reported as NO_INIT at LOW confidence rather than pretending they're traced.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from ._models import CallSite, Confidence, FileScan, Provider
|
|
17
|
+
|
|
18
|
+
# import specifier substring -> provider (used to know what a file touches)
|
|
19
|
+
_IMPORT_PROVIDER = [
|
|
20
|
+
("@anthropic-ai/sdk", Provider.ANTHROPIC),
|
|
21
|
+
("@google/generative-ai", Provider.GEMINI),
|
|
22
|
+
("@google/genai", Provider.GEMINI),
|
|
23
|
+
("openai", Provider.OPENAI),
|
|
24
|
+
("@langchain", Provider.LANGCHAIN),
|
|
25
|
+
("langchain", Provider.LANGCHAIN),
|
|
26
|
+
("litellm", Provider.LITELLM),
|
|
27
|
+
("cohere-ai", Provider.COHERE),
|
|
28
|
+
("@mistralai", Provider.MISTRAL),
|
|
29
|
+
("@aws-sdk/client-bedrock", Provider.BEDROCK),
|
|
30
|
+
("ollama", Provider.OLLAMA),
|
|
31
|
+
("groq-sdk", Provider.GROQ),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# call-chain regex -> provider it identifies
|
|
35
|
+
_CALL_PATTERNS = [
|
|
36
|
+
(re.compile(r"\.messages\.create\s*\("), Provider.ANTHROPIC),
|
|
37
|
+
(re.compile(r"\.messages\.stream\s*\("), Provider.ANTHROPIC),
|
|
38
|
+
(re.compile(r"\.chat\.completions\.create\s*\("), Provider.OPENAI),
|
|
39
|
+
(re.compile(r"\.responses\.create\s*\("), Provider.OPENAI),
|
|
40
|
+
(re.compile(r"\.generateContent\s*\("), Provider.GEMINI),
|
|
41
|
+
(re.compile(r"\.generateContentStream\s*\("), Provider.GEMINI),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_INIT_RE = re.compile(r"blitz\.init\s*\(")
|
|
45
|
+
_WORKFLOW_RE = re.compile(r"blitz\.workflow\s*\(")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_js(path: Path, source: str, rel_path: str) -> FileScan:
|
|
49
|
+
lines = source.splitlines()
|
|
50
|
+
imported: List[Provider] = []
|
|
51
|
+
has_init = False
|
|
52
|
+
for ln in lines:
|
|
53
|
+
if _INIT_RE.search(ln):
|
|
54
|
+
has_init = True
|
|
55
|
+
for spec, prov in _IMPORT_PROVIDER:
|
|
56
|
+
if ("import" in ln or "require" in ln) and spec in ln and prov not in imported:
|
|
57
|
+
imported.append(prov)
|
|
58
|
+
|
|
59
|
+
sites: List[CallSite] = []
|
|
60
|
+
for i, ln in enumerate(lines, start=1):
|
|
61
|
+
for pat, prov in _CALL_PATTERNS:
|
|
62
|
+
m = pat.search(ln)
|
|
63
|
+
if not m:
|
|
64
|
+
continue
|
|
65
|
+
# Prefer a provider actually imported in this file; a bare
|
|
66
|
+
# `.responses.create` in a groq/azure file still reads as its shape.
|
|
67
|
+
provider = prov
|
|
68
|
+
sites.append(
|
|
69
|
+
CallSite(
|
|
70
|
+
path=rel_path,
|
|
71
|
+
line=i,
|
|
72
|
+
provider=provider,
|
|
73
|
+
lang="js",
|
|
74
|
+
call_expr=m.group(0).rstrip("(").strip(),
|
|
75
|
+
enclosing_func=_nearest_func(lines, i - 1),
|
|
76
|
+
in_named_workflow=bool(_WORKFLOW_RE.search(ln)),
|
|
77
|
+
in_untraced=False,
|
|
78
|
+
confidence=Confidence.LOW,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return FileScan(sites=sites, has_init=has_init)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_FUNC_RES = [
|
|
85
|
+
re.compile(r"function\s+([A-Za-z0-9_$]+)"),
|
|
86
|
+
re.compile(r"(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?\("),
|
|
87
|
+
re.compile(r"([A-Za-z0-9_$]+)\s*\([^)]*\)\s*\{"),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _nearest_func(lines: List[str], idx: int) -> Optional[str]:
|
|
92
|
+
"""Best-effort: the nearest preceding function-ish declaration name."""
|
|
93
|
+
for j in range(idx, max(-1, idx - 60), -1):
|
|
94
|
+
for rx in _FUNC_RES:
|
|
95
|
+
m = rx.search(lines[j])
|
|
96
|
+
if m:
|
|
97
|
+
return m.group(1)
|
|
98
|
+
return None
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Data model for the codebase scan: providers, tiers, and per-call records.
|
|
2
|
+
|
|
3
|
+
Everything here is stdlib-only (``dataclasses`` + ``enum``) so the whole scan
|
|
4
|
+
subpackage stays dependency-free like the rest of blitz-cli. The detectors
|
|
5
|
+
(``_python``/``_js``) emit ``CallSite`` drafts; ``_classify`` fills in ``tier``;
|
|
6
|
+
``_report`` renders them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Provider(str, Enum):
|
|
17
|
+
"""Every LLM provider we can name. Only ``SUPPORTED`` ones are auto-traced
|
|
18
|
+
by ``blitz.init()``; the rest are invisible to Blitz today."""
|
|
19
|
+
|
|
20
|
+
OPENAI = "openai"
|
|
21
|
+
ANTHROPIC = "anthropic"
|
|
22
|
+
GEMINI = "gemini"
|
|
23
|
+
LANGCHAIN = "langchain"
|
|
24
|
+
PYDANTIC_AI = "pydantic-ai"
|
|
25
|
+
LITELLM = "litellm"
|
|
26
|
+
COHERE = "cohere"
|
|
27
|
+
MISTRAL = "mistral"
|
|
28
|
+
BEDROCK = "bedrock"
|
|
29
|
+
VERTEX = "vertex"
|
|
30
|
+
OLLAMA = "ollama"
|
|
31
|
+
GROQ = "groq"
|
|
32
|
+
UNKNOWN = "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Providers whose calls blitz traces after a single blitz.init().
|
|
36
|
+
# - openai / anthropic / gemini: directly auto-instrumented via OpenLLMetry.
|
|
37
|
+
# - pydantic-ai: a thin typed wrapper that always calls the official
|
|
38
|
+
# openai/anthropic/gemini SDKs at the exact methods OpenLLMetry patches
|
|
39
|
+
# (verified: the pinned anthropic instrumentor wraps beta.messages.AsyncMessages
|
|
40
|
+
# .create, which is what pydantic-ai's AnthropicModel calls). So it IS traced
|
|
41
|
+
# once init runs — the gate is init, not the framework. (Caveat: a pydantic-ai
|
|
42
|
+
# model over a non-supported provider, e.g. groq/mistral, would not be traced.)
|
|
43
|
+
SUPPORTED = {
|
|
44
|
+
Provider.OPENAI,
|
|
45
|
+
Provider.ANTHROPIC,
|
|
46
|
+
Provider.GEMINI,
|
|
47
|
+
Provider.PYDANTIC_AI,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Tier(str, Enum):
|
|
52
|
+
"""Instrumentation posture of a single AI call, worst-first when reported."""
|
|
53
|
+
|
|
54
|
+
UNSUPPORTED = "unsupported" # provider blitz can't auto-trace at all
|
|
55
|
+
NO_INIT = "no_init" # supported, but the project never calls blitz.init()
|
|
56
|
+
UNNAMED = "unnamed" # traced, but not under a named workflow()
|
|
57
|
+
INSTRUMENTED = "instrumented" # supported + init + under a workflow()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Confidence(str, Enum):
|
|
61
|
+
HIGH = "high" # python ast, lexical facts certain
|
|
62
|
+
MEDIUM = "medium" # python ast, but the call-graph caveat may apply
|
|
63
|
+
LOW = "low" # js/ts regex heuristic
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CallSite:
|
|
68
|
+
"""One detected AI/LLM call and everything we know about how it's wrapped."""
|
|
69
|
+
|
|
70
|
+
path: str # relative to the scan root, posix-style
|
|
71
|
+
line: int
|
|
72
|
+
provider: Provider
|
|
73
|
+
lang: str # "python" | "js"
|
|
74
|
+
call_expr: str # e.g. "client.messages.create"
|
|
75
|
+
enclosing_func: Optional[str] = None # for the workflow-name suggestion
|
|
76
|
+
in_named_workflow: bool = False # lexical `with blitz.workflow` OR decorator
|
|
77
|
+
in_untraced: bool = False # inside `with _untraced():` — suppressed on purpose
|
|
78
|
+
confidence: Confidence = Confidence.HIGH
|
|
79
|
+
tier: Optional[Tier] = None # filled by _classify
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ProjectSignals:
|
|
84
|
+
"""Tree-wide facts that decide NO_INIT vs UNNAMED for the whole scan."""
|
|
85
|
+
|
|
86
|
+
has_python_init: bool = False # `blitz.init(` resolved anywhere in the .py tree
|
|
87
|
+
has_js_init: bool = False # best-effort; a JS SDK may not exist yet
|
|
88
|
+
files_scanned: int = 0
|
|
89
|
+
|
|
90
|
+
def has_init(self, lang: str) -> bool:
|
|
91
|
+
return self.has_python_init if lang == "python" else self.has_js_init
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class FileScan:
|
|
96
|
+
"""What a single-file detector returns before project-wide aggregation."""
|
|
97
|
+
|
|
98
|
+
sites: List[CallSite] = field(default_factory=list)
|
|
99
|
+
has_init: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ScanResult:
|
|
104
|
+
root: str
|
|
105
|
+
signals: ProjectSignals = field(default_factory=ProjectSignals)
|
|
106
|
+
sites: List[CallSite] = field(default_factory=list)
|
|
107
|
+
skipped: List[str] = field(default_factory=list) # files we couldn't parse
|
|
108
|
+
|
|
109
|
+
def misses(self) -> List[CallSite]:
|
|
110
|
+
"""Sites a user should act on: anything not fully instrumented, minus the
|
|
111
|
+
intentionally-suppressed ``_untraced()`` calls."""
|
|
112
|
+
return [
|
|
113
|
+
s
|
|
114
|
+
for s in self.sites
|
|
115
|
+
if not s.in_untraced and s.tier is not Tier.INSTRUMENTED
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
def by_tier(self) -> Dict[Tier, List[CallSite]]:
|
|
119
|
+
out: Dict[Tier, List[CallSite]] = {t: [] for t in Tier}
|
|
120
|
+
for s in self.sites:
|
|
121
|
+
if s.tier is not None:
|
|
122
|
+
out[s.tier].append(s)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
def untraced(self) -> List[CallSite]:
|
|
126
|
+
return [s for s in self.sites if s.in_untraced]
|