Skip to content

Commit 4b22528

Browse files
committed
refactor(context): add ConnectorType enum, python_sources/trees on context, severity helpers
1 parent 745a718 commit 4b22528

5 files changed

Lines changed: 205 additions & 88 deletions

File tree

shared/connector_linter/connector_linter/formatters.py

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
from pathlib import Path
55
from typing import TextIO
66

7-
from connector_linter.models import CheckResult, Severity
7+
from connector_linter.models import SEVERITY_COLOR, CheckResult, Severity
88

9-
# ANSI color codes
109
_COLORS = {
1110
"green": "\033[32m",
1211
"red": "\033[31m",
@@ -19,17 +18,30 @@
1918

2019

2120
def _use_color(stream: TextIO) -> bool:
22-
"""Determine if we should use ANSI colors on this stream."""
2321
return hasattr(stream, "isatty") and stream.isatty()
2422

2523

2624
def _c(text: str, color: str, stream: TextIO) -> str:
27-
"""Colorize text if the stream supports it."""
2825
if not _use_color(stream):
2926
return text
3027
return f"{_COLORS.get(color, '')}{text}{_COLORS['reset']}"
3128

3229

30+
def _group_results(
31+
results: list[CheckResult],
32+
) -> tuple[list[CheckResult], list[CheckResult], list[CheckResult]]:
33+
"""Return (failed, advisory, passed_normal).
34+
35+
- failed — ``passed=False``, any severity
36+
- advisory — ``passed=True`` + ``WARNING`` (informational, connector still compliant)
37+
- passed_normal — ``passed=True`` + non-WARNING
38+
"""
39+
failed = [r for r in results if r.severity == Severity.ERROR]
40+
advisory = [r for r in results if r.severity == Severity.WARNING]
41+
passed_normal = [r for r in results if r.severity == Severity.INFO]
42+
return failed, advisory, passed_normal
43+
44+
3345
def _repo_relative_path(connector_path: Path, file_path: Path | None) -> str:
3446
"""Resolve a file_path to be relative to the git repository root.
3547
@@ -104,7 +116,7 @@ def _format_result_line(
104116
else:
105117
status = _c("FAIL", "red", stream)
106118

107-
code = _c(result.code, "cyan", stream)
119+
code = _c(result.code, SEVERITY_COLOR[result.severity], stream)
108120
return f" {location}: {code} [{status}] {result.message}"
109121

110122

@@ -153,25 +165,27 @@ def format_text(
153165
) -> None:
154166
"""Format results as human-readable text with colors.
155167
156-
By default, only failures (FAIL), warnings (WARN), and the score summary
157-
are displayed. Use ``verbose=True`` to also show passing checks (PASS).
168+
Grouping rules (default mode, i.e. ``verbose=False``):
169+
170+
- **failed** (“FAIL” / “WARN”) — all results with ``passed=False``, any severity.
171+
Suggestions are shown below each failing line.
172+
- **advisory** (“WARN”) — results with ``passed=True`` and
173+
``severity=WARNING``. These carry notes but do not fail the connector.
174+
- **passed** (“PASS”) — only shown when ``verbose=True``.
158175
"""
159-
failed = [r for r in results if r.severity == Severity.ERROR]
160-
warnings = [r for r in results if r.severity == Severity.WARNING]
161-
passed_normal = [r for r in results if r.severity == Severity.INFO]
176+
failed, advisory, passed_normal = _group_results(results)
162177

163178
def _write_result(result: CheckResult) -> None:
164179
stream.write(
165180
f"{_format_result_line(result, connector_path, stream, abspath=abspath)}\n",
166181
)
167182
if result.suggestion:
168-
suggestion = _c(f" ↳ {result.suggestion}", "dim", stream)
169-
stream.write(f"{suggestion}\n")
183+
stream.write(f"{_c(f' \u21b3 {result.suggestion}', 'dim', stream)}\n")
170184

171185
for result in failed:
172186
_write_result(result)
173187

174-
for result in warnings:
188+
for result in advisory:
175189
_write_result(result)
176190

177191
if verbose:
@@ -188,7 +202,6 @@ def format_json(
188202
stream: TextIO,
189203
) -> None:
190204
"""Format results as JSON."""
191-
output_results = results
192205
total = len(results)
193206
passed_count = len([r for r in results if r.severity != Severity.ERROR])
194207

@@ -212,7 +225,7 @@ def format_json(
212225
"line": r.line,
213226
"suggestion": r.suggestion,
214227
}
215-
for r in output_results
228+
for r in results
216229
],
217230
}
218231
json.dump(output, stream, indent=2)
@@ -235,9 +248,9 @@ def format_markdown(
235248
stream.write(f"# Connector Linter Report — `{connector_name}`\n\n")
236249

237250
total = len(results)
238-
passed_count = len([r for r in results if r.passed])
251+
passed_count = len([r for r in results if r.severity != Severity.ERROR])
239252
failed_count = total - passed_count
240-
errors = len([r for r in results if not r.passed and r.severity == Severity.ERROR])
253+
errors = len([r for r in results if r.severity == Severity.ERROR])
241254
warnings = len([r for r in results if r.severity == Severity.WARNING])
242255
pct = (passed_count / total) * 100 if total else 0
243256

@@ -253,9 +266,7 @@ def format_markdown(
253266
summary_parts.append(f"{warnings} warning(s)")
254267
stream.write(f"{', '.join(summary_parts)}\n\n")
255268

256-
failed = [r for r in results if not r.passed]
257-
warn_results = [r for r in results if r.passed and r.severity == Severity.WARNING]
258-
passed_normal = [r for r in results if r.passed and r.severity != Severity.WARNING]
269+
failed, advisory, passed_normal = _group_results(results)
259270

260271
def _md_path(r: CheckResult) -> str:
261272
if abspath:
@@ -276,9 +287,9 @@ def _md_line(r: CheckResult, icon: str) -> str:
276287
stream.write(f"{_md_line(r, '❌')}\n")
277288
stream.write("\n")
278289

279-
if warn_results:
280-
stream.write("## ⚠️ Warnings\n\n")
281-
for r in warn_results:
290+
if advisory:
291+
stream.write("## ⚠️ Advisories\n\n")
292+
for r in advisory:
282293
stream.write(f"{_md_line(r, '⚠️')}\n")
283294
stream.write("\n")
284295

shared/connector_linter/connector_linter/models.py

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
"""Data models for the connector linter."""
22

3+
import ast
34
import json
45
from dataclasses import dataclass, field
56
from enum import StrEnum
7+
from functools import cached_property
68
from pathlib import Path
79
from typing import Any
810

911

12+
class ConnectorType(StrEnum):
13+
"""Known OpenCTI connector types."""
14+
15+
EXTERNAL_IMPORT = "EXTERNAL_IMPORT"
16+
INTERNAL_ENRICHMENT = "INTERNAL_ENRICHMENT"
17+
INTERNAL_EXPORT_FILE = "INTERNAL_EXPORT_FILE"
18+
INTERNAL_IMPORT_FILE = "INTERNAL_IMPORT_FILE"
19+
STREAM = "STREAM"
20+
21+
@property
22+
def label(self) -> str:
23+
"""Human-readable label derived from the value.
24+
25+
Examples: EXTERNAL_IMPORT → 'External Import', STREAM → 'Stream'.
26+
"""
27+
return self.value.replace("_", " ").title()
28+
29+
1030
class Severity(StrEnum):
1131
"""Severity levels for check results."""
1232

@@ -18,6 +38,23 @@ def symbol(self) -> str:
1838
"""Get a short symbol for the severity level."""
1939
return {"error": "E", "warning": "W", "info": "I"}[self.value]
2040

41+
def rank(self) -> int:
42+
"""Numeric rank for ordering (INFO=0, WARNING=1, ERROR=2)."""
43+
return {"info": 0, "warning": 1, "error": 2}[self.value]
44+
45+
46+
# Shared severity → display mappings. Keyed by Severity enum for direct lookup.
47+
SEVERITY_EMOJI: dict["Severity", str] = {
48+
Severity.ERROR: "🔴",
49+
Severity.WARNING: "🟡",
50+
Severity.INFO: "🔵",
51+
}
52+
SEVERITY_COLOR: dict["Severity", str] = {
53+
Severity.ERROR: "red",
54+
Severity.WARNING: "yellow",
55+
Severity.INFO: "cyan",
56+
}
57+
2158

2259
@dataclass
2360
class CheckFinding:
@@ -48,12 +85,30 @@ class CheckResult:
4885
suggestion: str | None = None
4986

5087

88+
def no_python_sources_finding(suggestion: str | None = None) -> "CheckFinding":
89+
"""Standard finding for checks that require Python source files but find none."""
90+
return CheckFinding(
91+
message="No Python source files found in src/",
92+
severity=Severity.ERROR,
93+
suggestion=suggestion or "Connector must have Python source files under src/",
94+
)
95+
96+
97+
_DIR_TO_CONNECTOR_TYPE: dict[str, ConnectorType] = {
98+
"external-import": ConnectorType.EXTERNAL_IMPORT,
99+
"internal-enrichment": ConnectorType.INTERNAL_ENRICHMENT,
100+
"internal-export-file": ConnectorType.INTERNAL_EXPORT_FILE,
101+
"internal-import-file": ConnectorType.INTERNAL_IMPORT_FILE,
102+
"stream": ConnectorType.STREAM,
103+
}
104+
105+
51106
@dataclass
52107
class ConnectorContext:
53108
"""Contextual data about a connector, loaded once and shared across checks."""
54109

55110
path: Path
56-
connector_type: str | None = None
111+
connector_type: ConnectorType | None = None
57112
manifest: dict[str, Any] = field(default_factory=dict)
58113
config_schema: dict[str, Any] = field(default_factory=dict)
59114
has_tests: bool = False
@@ -63,34 +118,65 @@ class ConnectorContext:
63118
src_files: list[Path] = field(default_factory=list)
64119
all_files: list[Path] = field(default_factory=list)
65120

121+
@cached_property
122+
def python_sources(self) -> dict[Path, str]:
123+
"""All Python source files under src/, keyed by path relative to connector root.
124+
125+
Computed once and cached for the lifetime of this context.
126+
Uses src_files populated at load time to avoid re-scanning the filesystem.
127+
"""
128+
sources: dict[Path, str] = {}
129+
for rel_path in self.src_files:
130+
abs_path = self.path / rel_path
131+
try:
132+
sources[rel_path] = abs_path.read_text(
133+
encoding="utf-8", errors="replace"
134+
)
135+
except OSError:
136+
continue
137+
return sources
138+
139+
@cached_property
140+
def python_trees(self) -> dict[Path, ast.Module]:
141+
"""Parsed AST modules for all Python source files.
142+
143+
Computed once and cached for the lifetime of this context.
144+
Files with syntax errors are silently skipped.
145+
"""
146+
trees: dict[Path, ast.Module] = {}
147+
for file_path, content in self.python_sources.items():
148+
try:
149+
trees[file_path] = ast.parse(content, filename=str(file_path))
150+
except SyntaxError:
151+
continue
152+
return trees
153+
66154
@classmethod
67155
def load(cls, connector_path: Path) -> "ConnectorContext":
68156
"""Load connector context from its directory."""
69157
ctx = cls(path=connector_path.resolve())
70158

71159
# Detect connector type from parent directory name
72-
parent_name = ctx.path.parent.name
73-
type_mapping = {
74-
"external-import": "EXTERNAL_IMPORT",
75-
"internal-enrichment": "INTERNAL_ENRICHMENT",
76-
"internal-export-file": "INTERNAL_EXPORT_FILE",
77-
"internal-import-file": "INTERNAL_IMPORT_FILE",
78-
"stream": "STREAM",
79-
}
80-
ctx.connector_type = type_mapping.get(parent_name)
160+
ctx.connector_type = _DIR_TO_CONNECTOR_TYPE.get(ctx.path.parent.name)
81161
# Fallback only for template layout: templates/<connector-kind>
82-
if ctx.connector_type is None and parent_name == "templates":
83-
ctx.connector_type = type_mapping.get(ctx.path.name)
162+
if ctx.connector_type is None and ctx.path.parent.name == "templates":
163+
ctx.connector_type = _DIR_TO_CONNECTOR_TYPE.get(ctx.path.name)
84164

85165
# Load manifest
86166
manifest_path = ctx.path / "__metadata__" / "connector_manifest.json"
87167
if manifest_path.exists():
88-
with manifest_path.open() as f:
89-
ctx.manifest = json.load(f)
168+
try:
169+
with manifest_path.open() as f:
170+
ctx.manifest = json.load(f)
171+
except (json.JSONDecodeError, OSError):
172+
pass # malformed or unreadable — checks that need it will report missing fields
90173

91174
# Fallback: use container_type from manifest
92175
if ctx.connector_type is None and ctx.manifest.get("container_type"):
93-
ctx.connector_type = ctx.manifest["container_type"]
176+
try:
177+
ctx.connector_type = ConnectorType(ctx.manifest["container_type"])
178+
except ValueError:
179+
pass # unknown type string — leave as None
94180

95181
if ctx.connector_type is None:
96182
raise ValueError(
@@ -105,8 +191,11 @@ def load(cls, connector_path: Path) -> "ConnectorContext":
105191
# Load config schema
106192
schema_path = ctx.path / "__metadata__" / "connector_config_schema.json"
107193
if schema_path.exists():
108-
with schema_path.open() as f:
109-
ctx.config_schema = json.load(f)
194+
try:
195+
with schema_path.open() as f:
196+
ctx.config_schema = json.load(f)
197+
except (json.JSONDecodeError, OSError):
198+
pass # malformed or unreadable — leave as empty dict
110199

111200
# Detect structural elements
112201
ctx.has_metadata_dir = (ctx.path / "__metadata__").is_dir()

shared/connector_linter/connector_linter/noqa.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,29 +67,21 @@ def get_noqa_directives(file_path: Path) -> dict[int, set[str] | None]:
6767
def is_suppressed(result: CheckResult, file_path: Path, line: int) -> bool:
6868
"""Check if a result is suppressed by a noqa directive on its line."""
6969
directives = get_noqa_directives(file_path)
70-
directive = directives.get(line)
71-
72-
if directive is None and line in directives:
73-
return True # bare noqa — suppress everything
74-
75-
return directive is not None and result.code.upper() in directive
70+
if line not in directives:
71+
return False
72+
codes = directives[line]
73+
return codes is None or result.code.upper() in codes # None = bare noqa
7674

7775

7876
def filter_noqa(
7977
results: list[CheckResult],
8078
connector_path: Path,
8179
) -> list[CheckResult]:
82-
"""Filter results that are suppressed by noqa directives.
83-
84-
Only results with both ``file_path`` and ``line`` set are eligible
85-
for suppression. Results without location info pass through unchanged.
80+
"""Filter results suppressed by noqa directives.
8681
87-
*connector_path* is the connector root directory. When a result carries
88-
a relative ``file_path`` (common — most checks report paths relative to
89-
the connector root), it is resolved against *connector_path* so that
90-
``_read_file_lines`` opens the correct file on disk.
82+
Results without both ``file_path`` and ``line`` pass through unchanged.
83+
Relative paths are resolved against *connector_path*.
9184
"""
92-
_read_file_lines.cache_clear()
9385
resolved_root = connector_path.resolve()
9486
filtered: list[CheckResult] = []
9587

0 commit comments

Comments
 (0)