spanforge 1.0.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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/lint.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""spanforge.lint — SDK instrumentation linter.
|
|
2
|
+
|
|
3
|
+
Inspects Python source files for common spanforge instrumentation mistakes
|
|
4
|
+
before the code runs. Ships as:
|
|
5
|
+
|
|
6
|
+
1. A **Python API** — call ``run_checks()`` from test suites or CI scripts.
|
|
7
|
+
2. A **flake8 plugin** — registered via ``[project.entry-points."flake8.extension"]``
|
|
8
|
+
in ``pyproject.toml`` so AO-codes appear inline in editor linting.
|
|
9
|
+
3. A **CLI** — ``python -m spanforge.lint [FILES_OR_DIRS...]``.
|
|
10
|
+
|
|
11
|
+
AO error codes
|
|
12
|
+
--------------
|
|
13
|
+
AO000 Syntax error in source file.
|
|
14
|
+
AO001 Event() is missing a required field ('event_type', 'source', or 'payload').
|
|
15
|
+
AO002 Identity field ('actor_id', 'session_id', 'user_id') receives a bare str literal.
|
|
16
|
+
AO003 event_type string is not a registered EventType value.
|
|
17
|
+
AO004 LLM provider call detected outside a tracer span context.
|
|
18
|
+
AO005 emit_span / emit_agent_* called outside agent_run() / agent_step() context.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"LintError",
|
|
31
|
+
"SpanForgeChecker",
|
|
32
|
+
"run_checks",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# LintError
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_REQUIRED_EVENT_FIELDS = frozenset({"event_type", "source", "payload"})
|
|
40
|
+
_IDENTITY_FIELDS = frozenset({"actor_id", "session_id", "user_id"})
|
|
41
|
+
|
|
42
|
+
# Patterns for LLM provider calls (AO004)
|
|
43
|
+
_LLM_CALL_PATTERNS = re.compile(
|
|
44
|
+
r"(?:chat\.completions\.create|messages\.create|completions\.create|"
|
|
45
|
+
r"\.generate\s*\(|\.complete\s*\()"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Context-manager call names for AO004/AO005 checks
|
|
49
|
+
_SPAN_CONTEXT_NAMES = frozenset({"span", "agent_run", "agent_step"})
|
|
50
|
+
_EMIT_NAMES = frozenset({"emit_span", "emit_agent_run", "emit_agent_step"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class LintError:
|
|
55
|
+
"""An immutable lint finding.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
code: AO-code, e.g. ``"AO001"``.
|
|
59
|
+
message: Human-readable description.
|
|
60
|
+
filename: File the error was found in.
|
|
61
|
+
line: 1-based line number.
|
|
62
|
+
col: 1-based column number.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
code: str
|
|
66
|
+
message: str
|
|
67
|
+
filename: str
|
|
68
|
+
line: int
|
|
69
|
+
col: int
|
|
70
|
+
|
|
71
|
+
def __str__(self) -> str:
|
|
72
|
+
return f"{self.filename}:{self.line}:{self.col}: {self.code} {self.message}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# AST visitor
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _SpanForgeVisitor(ast.NodeVisitor):
|
|
81
|
+
"""Walk an AST and collect AO-code lint errors."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, filename: str) -> None:
|
|
84
|
+
self.filename = filename
|
|
85
|
+
self.errors: list[LintError] = []
|
|
86
|
+
# Stack tracking whether we are inside a span/agent context manager
|
|
87
|
+
self._in_span_context: int = 0
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# AO001 — Missing required Event() field
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
|
|
94
|
+
func_name = _get_call_name(node)
|
|
95
|
+
|
|
96
|
+
if func_name in ("Event", "spanforge.Event"):
|
|
97
|
+
keyword_names = {kw.arg for kw in node.keywords}
|
|
98
|
+
for required in _REQUIRED_EVENT_FIELDS:
|
|
99
|
+
if required not in keyword_names:
|
|
100
|
+
self.errors.append(
|
|
101
|
+
LintError(
|
|
102
|
+
code="AO001",
|
|
103
|
+
message=f"Event() is missing required field '{required}'",
|
|
104
|
+
filename=self.filename,
|
|
105
|
+
line=node.lineno,
|
|
106
|
+
col=node.col_offset + 1,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# AO002 — bare str literal for identity field
|
|
111
|
+
for kw in node.keywords:
|
|
112
|
+
if (
|
|
113
|
+
kw.arg in _IDENTITY_FIELDS
|
|
114
|
+
and isinstance(kw.value, ast.Constant)
|
|
115
|
+
and isinstance(kw.value.value, str)
|
|
116
|
+
):
|
|
117
|
+
self.errors.append(
|
|
118
|
+
LintError(
|
|
119
|
+
code="AO002",
|
|
120
|
+
message=(
|
|
121
|
+
f"'{kw.arg}' receives a bare str literal; wrap with Redactable()"
|
|
122
|
+
),
|
|
123
|
+
filename=self.filename,
|
|
124
|
+
line=kw.value.lineno,
|
|
125
|
+
col=kw.value.col_offset + 1,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# AO003 — unknown event_type string
|
|
130
|
+
for kw in node.keywords:
|
|
131
|
+
if (
|
|
132
|
+
kw.arg == "event_type"
|
|
133
|
+
and isinstance(kw.value, ast.Constant)
|
|
134
|
+
and isinstance(kw.value.value, str)
|
|
135
|
+
):
|
|
136
|
+
value = kw.value.value
|
|
137
|
+
if not _is_registered_event_type(value):
|
|
138
|
+
self.errors.append(
|
|
139
|
+
LintError(
|
|
140
|
+
code="AO003",
|
|
141
|
+
message=f"event_type string '{value}' is not a registered EventType value",
|
|
142
|
+
filename=self.filename,
|
|
143
|
+
line=kw.value.lineno,
|
|
144
|
+
col=kw.value.col_offset + 1,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# AO005 — emit_* outside agent context
|
|
149
|
+
if func_name in _EMIT_NAMES and self._in_span_context == 0:
|
|
150
|
+
self.errors.append(
|
|
151
|
+
LintError(
|
|
152
|
+
code="AO005",
|
|
153
|
+
message=f"{func_name} called outside agent_run() / agent_step() context",
|
|
154
|
+
filename=self.filename,
|
|
155
|
+
line=node.lineno,
|
|
156
|
+
col=node.col_offset + 1,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self.generic_visit(node)
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# AO004 — LLM provider call outside span context (with-statement tracking)
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def visit_With(self, node: ast.With) -> None: # noqa: N802
|
|
167
|
+
is_span = any(
|
|
168
|
+
isinstance(item.context_expr, ast.Call)
|
|
169
|
+
and _get_call_name(item.context_expr) in _SPAN_CONTEXT_NAMES
|
|
170
|
+
for item in node.items
|
|
171
|
+
)
|
|
172
|
+
if is_span:
|
|
173
|
+
self._in_span_context += 1
|
|
174
|
+
self.generic_visit(node)
|
|
175
|
+
if is_span:
|
|
176
|
+
self._in_span_context -= 1
|
|
177
|
+
|
|
178
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> None: # noqa: N802
|
|
179
|
+
is_span = any(
|
|
180
|
+
isinstance(item.context_expr, ast.Call)
|
|
181
|
+
and _get_call_name(item.context_expr) in _SPAN_CONTEXT_NAMES
|
|
182
|
+
for item in node.items
|
|
183
|
+
)
|
|
184
|
+
if is_span:
|
|
185
|
+
self._in_span_context += 1
|
|
186
|
+
self.generic_visit(node)
|
|
187
|
+
if is_span:
|
|
188
|
+
self._in_span_context -= 1
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get_call_name(node: ast.Call) -> str:
|
|
192
|
+
"""Return a dotted name string for a Call node's function, or empty string."""
|
|
193
|
+
func = node.func
|
|
194
|
+
if isinstance(func, ast.Name):
|
|
195
|
+
return func.id
|
|
196
|
+
if isinstance(func, ast.Attribute):
|
|
197
|
+
return func.attr
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_registered_event_type(value: str) -> bool:
|
|
202
|
+
"""Return True if *value* is a registered EventType string (best-effort)."""
|
|
203
|
+
try:
|
|
204
|
+
from spanforge.types import EventType as _ET
|
|
205
|
+
|
|
206
|
+
return value in {m.value for m in _ET}
|
|
207
|
+
except (ImportError, TypeError):
|
|
208
|
+
# If we cannot import EventType, assume valid to avoid false positives
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Public run_checks() API
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def run_checks(source: str, filename: str = "<string>") -> list[LintError]:
|
|
218
|
+
"""Parse *source* as valid Python 3 and return a list of :class:`LintError` objects.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
source: UTF-8 Python source code to analyse.
|
|
222
|
+
filename: File path; used in :attr:`LintError.filename`.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
List of :class:`LintError` objects sorted by ``(line, col)``.
|
|
226
|
+
Empty list when the file is clean.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
tree = ast.parse(source, filename=filename)
|
|
230
|
+
except SyntaxError as exc:
|
|
231
|
+
return [
|
|
232
|
+
LintError(
|
|
233
|
+
code="AO000",
|
|
234
|
+
message=f"Syntax error: {exc.msg}",
|
|
235
|
+
filename=filename,
|
|
236
|
+
line=exc.lineno or 1,
|
|
237
|
+
col=exc.offset or 1,
|
|
238
|
+
)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
visitor = _SpanForgeVisitor(filename=filename)
|
|
242
|
+
visitor.visit(tree)
|
|
243
|
+
return sorted(visitor.errors, key=lambda e: (e.line, e.col))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Filesystem helpers used by CLI and flake8 plugin
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _iter_python_files(paths: list[str]) -> Iterator[Path]:
|
|
252
|
+
"""Yield all ``*.py`` files under the given *paths*."""
|
|
253
|
+
for p_str in paths:
|
|
254
|
+
path = Path(p_str)
|
|
255
|
+
if path.is_file() and path.suffix == ".py":
|
|
256
|
+
yield path
|
|
257
|
+
elif path.is_dir():
|
|
258
|
+
yield from path.rglob("*.py")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# flake8 plugin shim (imported via entry-point AO = spanforge.lint._flake8)
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
# The _flake8 sub-module is registered as the entry-point; provide a minimal
|
|
266
|
+
# SpanForgeChecker class here too so ``from spanforge.lint import SpanForgeChecker``
|
|
267
|
+
# works without importing a separate sub-module.
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class SpanForgeChecker:
|
|
271
|
+
"""Minimal flake8-compatible checker that delegates to :func:`run_checks`.
|
|
272
|
+
|
|
273
|
+
flake8 discovers this class via the ``flake8.extension`` entry-point and
|
|
274
|
+
calls ``check_file()`` for each file it processes.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
name = "spanforge-lint"
|
|
278
|
+
version = "2.0.0"
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self, tree: ast.AST, filename: str = "<string>", lines: list[str] | None = None
|
|
282
|
+
) -> None:
|
|
283
|
+
self._tree = tree
|
|
284
|
+
self._filename = filename
|
|
285
|
+
self._source = "".join(lines) if lines else ""
|
|
286
|
+
|
|
287
|
+
def run(self) -> Iterator[tuple[int, int, str, type]]:
|
|
288
|
+
"""Yield ``(line, col, message, type)`` tuples for flake8."""
|
|
289
|
+
errors = run_checks(self._source, filename=self._filename)
|
|
290
|
+
for err in errors:
|
|
291
|
+
yield (err.line, err.col - 1, f"{err.code} {err.message}", type(self))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# CLI entry point: python -m spanforge.lint [FILES_OR_DIRS...]
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
import sys
|
|
300
|
+
|
|
301
|
+
paths = sys.argv[1:] or ["."]
|
|
302
|
+
total_errors = 0
|
|
303
|
+
files_with_errors = 0
|
|
304
|
+
|
|
305
|
+
for py_file in _iter_python_files(paths):
|
|
306
|
+
try:
|
|
307
|
+
source_code = py_file.read_text(encoding="utf-8")
|
|
308
|
+
except OSError as read_err:
|
|
309
|
+
print(f"spanforge.lint: cannot read {py_file}: {read_err}", file=sys.stderr)
|
|
310
|
+
sys.exit(2)
|
|
311
|
+
|
|
312
|
+
file_errors = run_checks(source_code, filename=str(py_file))
|
|
313
|
+
for err in file_errors:
|
|
314
|
+
print(str(err))
|
|
315
|
+
if file_errors:
|
|
316
|
+
total_errors += len(file_errors)
|
|
317
|
+
files_with_errors += 1
|
|
318
|
+
|
|
319
|
+
if total_errors:
|
|
320
|
+
print(f"{total_errors} error(s) in {files_with_errors} file(s).")
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
sys.exit(0)
|