topmark 0.3.2__tar.gz → 0.4.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.
- {topmark-0.3.2/src/topmark.egg-info → topmark-0.4.0}/PKG-INFO +1 -1
- {topmark-0.3.2 → topmark-0.4.0}/pyproject.toml +1 -10
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/api/__init__.py +50 -13
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/api/public_types.py +24 -1
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/api/types.py +27 -3
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/cli_types.py +5 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/cmd_common.py +3 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/check.py +0 -3
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/strip.py +0 -3
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/config_resolver.py +4 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/main.py +8 -0
- topmark-0.4.0/src/topmark/cli_shared/console.py +105 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli_shared/utils.py +1 -1
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/config/__init__.py +21 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/context.py +185 -5
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/pipelines.py +1 -2
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/base.py +6 -8
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/builder.py +2 -4
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/reader.py +5 -3
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/resolver.py +3 -5
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/stripper.py +1 -6
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/updater.py +4 -4
- {topmark-0.3.2 → topmark-0.4.0/src/topmark.egg-info}/PKG-INFO +1 -1
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/SOURCES.txt +1 -0
- {topmark-0.3.2 → topmark-0.4.0}/LICENSE +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/README.md +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/setup.cfg +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/dump_config.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/filetypes.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/init_config.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/processors.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/show_defaults.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/version.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/errors.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/io.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/options.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/utils.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli_shared/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli_shared/exit_codes.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/config/logging.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/config/topmark-default.toml +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/constants.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/file_resolver.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/base.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/instances.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/policy.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/registry.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/contracts.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/pound.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/slash.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/xml.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/runner.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/comparer.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/patcher.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/renderer.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/scanner.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/py.typed +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/filetypes.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/processors.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/registry.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/rendering/api.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/rendering/formats.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/utils/__init__.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/utils/diff.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark/utils/file.py +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/dependency_links.txt +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/entry_points.txt +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/requires.txt +0 -0
- {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/top_level.txt +0 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "topmark"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.4.0"
|
|
14
14
|
description = "A Python CLI to inspect, validate, and manage license and copyright headers."
|
|
15
15
|
authors = [{ name = "Olivier Biot", email = "olivier.biot@gmail.com" }]
|
|
16
16
|
license = "MIT"
|
|
@@ -113,15 +113,6 @@ include-package-data = true
|
|
|
113
113
|
[tool.setuptools.packages.find]
|
|
114
114
|
where = ["src"]
|
|
115
115
|
|
|
116
|
-
# Markdown formatting configuration (normative reference: .mdformat.toml)
|
|
117
|
-
# Aligns with Prettier in VS Code and the mdformat pre-commit hook
|
|
118
|
-
[tool.mdformat]
|
|
119
|
-
wrap = 100
|
|
120
|
-
extensions = ["tables"]
|
|
121
|
-
# Exclude auto-generated API page from formatting (Python 3.13)
|
|
122
|
-
# NOTE: Please use `make format-check` and `make format`
|
|
123
|
-
exclude = ["docs/api/public.md"]
|
|
124
|
-
|
|
125
116
|
|
|
126
117
|
# TOML formatting
|
|
127
118
|
[tool.taplo.format]
|
|
@@ -34,6 +34,7 @@ Notes:
|
|
|
34
34
|
- A FileType instance is **recognized** if it is in the FileTypeRegistry.
|
|
35
35
|
- A FileType instance is **supported** if it is recognized and is registered to
|
|
36
36
|
a HeaderProcessor instance in the HeaderProcessorRegistry.
|
|
37
|
+
- The API returns per-file diagnostics and aggregate counts; levels are "info", "warning", "error".
|
|
37
38
|
"""
|
|
38
39
|
|
|
39
40
|
from __future__ import annotations
|
|
@@ -52,10 +53,16 @@ from topmark.cli_shared.utils import write_updates
|
|
|
52
53
|
from topmark.config import Config
|
|
53
54
|
from topmark.config.logging import get_logger, setup_logging
|
|
54
55
|
from topmark.constants import TOPMARK_VERSION
|
|
55
|
-
from topmark.pipeline.context import
|
|
56
|
+
from topmark.pipeline.context import (
|
|
57
|
+
ComparisonStatus,
|
|
58
|
+
FileStatus,
|
|
59
|
+
ProcessingContext,
|
|
60
|
+
WriteStatus,
|
|
61
|
+
)
|
|
56
62
|
from topmark.registry import Registry
|
|
57
63
|
|
|
58
|
-
from .
|
|
64
|
+
from .public_types import PublicDiagnostic
|
|
65
|
+
from .types import DiagnosticTotals, FileResult, FileTypeInfo, Outcome, ProcessorInfo, RunResult
|
|
59
66
|
|
|
60
67
|
if "TOPMARK_DEBUG" in os.environ:
|
|
61
68
|
setup_logging(level=logging.DEBUG)
|
|
@@ -75,6 +82,7 @@ __all__ = [
|
|
|
75
82
|
"get_filetype_info",
|
|
76
83
|
"get_processor_info",
|
|
77
84
|
"Registry",
|
|
85
|
+
"PublicDiagnostic",
|
|
78
86
|
]
|
|
79
87
|
|
|
80
88
|
|
|
@@ -200,24 +208,45 @@ def _apply_view_filter(
|
|
|
200
208
|
return view_results, skipped
|
|
201
209
|
|
|
202
210
|
|
|
203
|
-
def _collect_diagnostics(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
Args:
|
|
209
|
-
results: List of ProcessingContext results.
|
|
211
|
+
def _collect_diagnostics(
|
|
212
|
+
results: list[ProcessingContext],
|
|
213
|
+
) -> dict[str, list[PublicDiagnostic]]:
|
|
214
|
+
"""Collect per-file diagnostics as a mapping `{path: [diagnostics...]}`.
|
|
210
215
|
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
Diagnostics are returned in the *public* JSON-friendly shape and do not
|
|
217
|
+
expose internal classes or enums.
|
|
213
218
|
"""
|
|
214
|
-
diags: dict[str, list[
|
|
219
|
+
diags: dict[str, list[PublicDiagnostic]] = {}
|
|
215
220
|
for r in results:
|
|
216
221
|
if r.diagnostics:
|
|
217
|
-
diags[str(r.path)] =
|
|
222
|
+
diags[str(r.path)] = [
|
|
223
|
+
{"level": d.level.value, "message": d.message} for d in r.diagnostics
|
|
224
|
+
]
|
|
218
225
|
return diags
|
|
219
226
|
|
|
220
227
|
|
|
228
|
+
def _collect_diagnostic_totals(results: list[ProcessingContext]) -> DiagnosticTotals:
|
|
229
|
+
"""Return aggregate counts of diagnostics across the given results.
|
|
230
|
+
|
|
231
|
+
Counts reflect only the supplied `results` (typically the **view** after
|
|
232
|
+
filtering), matching what is returned to the caller.
|
|
233
|
+
"""
|
|
234
|
+
total_info = total_warn = total_error = 0
|
|
235
|
+
for r in results:
|
|
236
|
+
if not r.diagnostics:
|
|
237
|
+
continue
|
|
238
|
+
for d in r.diagnostics:
|
|
239
|
+
lv = d.level.value
|
|
240
|
+
if lv == "info":
|
|
241
|
+
total_info += 1
|
|
242
|
+
elif lv == "warning":
|
|
243
|
+
total_warn += 1
|
|
244
|
+
elif lv == "error":
|
|
245
|
+
total_error += 1
|
|
246
|
+
total = total_info + total_warn + total_error
|
|
247
|
+
return {"info": total_info, "warning": total_warn, "error": total_error, "total": total}
|
|
248
|
+
|
|
249
|
+
|
|
221
250
|
# ---------- public API ----------
|
|
222
251
|
|
|
223
252
|
|
|
@@ -354,6 +383,8 @@ def check(
|
|
|
354
383
|
encountered_error_code is not None
|
|
355
384
|
)
|
|
356
385
|
diagnostics = _collect_diagnostics(view_results)
|
|
386
|
+
diagnostic_totals = _collect_diagnostic_totals(view_results)
|
|
387
|
+
diagnostic_totals_all = _collect_diagnostic_totals(results)
|
|
357
388
|
|
|
358
389
|
written = failed = 0
|
|
359
390
|
if apply:
|
|
@@ -380,6 +411,8 @@ def check(
|
|
|
380
411
|
written=written,
|
|
381
412
|
failed=failed,
|
|
382
413
|
diagnostics=diagnostics,
|
|
414
|
+
diagnostic_totals=diagnostic_totals,
|
|
415
|
+
diagnostic_totals_all=diagnostic_totals_all,
|
|
383
416
|
)
|
|
384
417
|
|
|
385
418
|
|
|
@@ -432,6 +465,8 @@ def strip(
|
|
|
432
465
|
encountered_error_code is not None
|
|
433
466
|
)
|
|
434
467
|
diagnostics = _collect_diagnostics(view_results)
|
|
468
|
+
diagnostic_totals = _collect_diagnostic_totals(view_results)
|
|
469
|
+
diagnostic_totals_all = _collect_diagnostic_totals(results)
|
|
435
470
|
|
|
436
471
|
written = failed = 0
|
|
437
472
|
if apply:
|
|
@@ -452,6 +487,8 @@ def strip(
|
|
|
452
487
|
written=written,
|
|
453
488
|
failed=failed,
|
|
454
489
|
diagnostics=diagnostics,
|
|
490
|
+
diagnostic_totals=diagnostic_totals,
|
|
491
|
+
diagnostic_totals_all=diagnostic_totals_all,
|
|
455
492
|
)
|
|
456
493
|
|
|
457
494
|
|
|
@@ -21,11 +21,17 @@ Notes:
|
|
|
21
21
|
* Attribute names and types here are part of the public API and follow
|
|
22
22
|
semver. Adding optional attributes is allowed in minor versions; removing
|
|
23
23
|
or changing types is a breaking change.
|
|
24
|
+
|
|
25
|
+
Diagnostics
|
|
26
|
+
-----------
|
|
27
|
+
The public API uses JSON-friendly diagnostics with string literal severities.
|
|
28
|
+
See :class:`PublicDiagnostic` for the shape and :data:`DiagnosticLevelLiteral`
|
|
29
|
+
for the allowed values.
|
|
24
30
|
"""
|
|
25
31
|
|
|
26
32
|
from __future__ import annotations
|
|
27
33
|
|
|
28
|
-
from typing import TYPE_CHECKING, Protocol, Sequence
|
|
34
|
+
from typing import TYPE_CHECKING, Literal, Protocol, Sequence, TypedDict
|
|
29
35
|
|
|
30
36
|
if TYPE_CHECKING:
|
|
31
37
|
from collections.abc import Callable
|
|
@@ -96,3 +102,20 @@ class PublicHeaderProcessor(Protocol):
|
|
|
96
102
|
block_suffix: str
|
|
97
103
|
# registry binds at runtime
|
|
98
104
|
file_type: PublicFileType
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
"""Allowed diagnostic severity levels in the public API."""
|
|
108
|
+
DiagnosticLevelLiteral = Literal["info", "warning", "error"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PublicDiagnostic(TypedDict):
|
|
112
|
+
"""Stable, JSON-friendly diagnostic for API consumers.
|
|
113
|
+
|
|
114
|
+
Notes:
|
|
115
|
+
* Uses string literal levels for semver stability and easy serialization.
|
|
116
|
+
* Mirrors internal pipeline diagnostics but does not expose internals.
|
|
117
|
+
* This shape is stable for semver and safe to serialize to JSON/NDJSON.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
level: DiagnosticLevelLiteral
|
|
121
|
+
message: str
|
|
@@ -21,9 +21,28 @@ from dataclasses import dataclass
|
|
|
21
21
|
from enum import Enum
|
|
22
22
|
from typing import TYPE_CHECKING, Mapping, Sequence, TypedDict
|
|
23
23
|
|
|
24
|
+
|
|
25
|
+
class DiagnosticTotals(TypedDict):
|
|
26
|
+
"""Aggregate diagnostic counts across the returned *view*.
|
|
27
|
+
|
|
28
|
+
Keys:
|
|
29
|
+
info: Number of info diagnostics.
|
|
30
|
+
warning: Number of warning diagnostics.
|
|
31
|
+
error: Number of error diagnostics.
|
|
32
|
+
total: Sum of all diagnostics.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
info: int
|
|
36
|
+
warning: int
|
|
37
|
+
error: int
|
|
38
|
+
total: int
|
|
39
|
+
|
|
40
|
+
|
|
24
41
|
if TYPE_CHECKING:
|
|
25
42
|
from pathlib import Path
|
|
26
43
|
|
|
44
|
+
from .public_types import PublicDiagnostic
|
|
45
|
+
|
|
27
46
|
|
|
28
47
|
class Outcome(str, Enum):
|
|
29
48
|
"""Per-file outcome bucket.
|
|
@@ -72,8 +91,11 @@ class RunResult:
|
|
|
72
91
|
skipped: Number of items removed by view filters (e.g., `skip_compliant`).
|
|
73
92
|
written: Number of files successfully written (only in apply mode; otherwise 0).
|
|
74
93
|
failed: Number of files that failed to write (only in apply mode; otherwise 0).
|
|
75
|
-
diagnostics: Optional mapping from file path
|
|
76
|
-
|
|
94
|
+
diagnostics: Optional mapping from file path to a list of public diagnostics.
|
|
95
|
+
diagnostic_totals: Optional aggregate counts across the **returned view**
|
|
96
|
+
(not the entire run).
|
|
97
|
+
diagnostic_totals_all: Optional aggregate counts across the **entire run**
|
|
98
|
+
(pre view filtering).
|
|
77
99
|
"""
|
|
78
100
|
|
|
79
101
|
files: Sequence[FileResult]
|
|
@@ -82,7 +104,9 @@ class RunResult:
|
|
|
82
104
|
skipped: int = 0
|
|
83
105
|
written: int = 0
|
|
84
106
|
failed: int = 0
|
|
85
|
-
diagnostics: dict[str, list[
|
|
107
|
+
diagnostics: dict[str, list[PublicDiagnostic]] | None = None
|
|
108
|
+
diagnostic_totals: DiagnosticTotals | None = None
|
|
109
|
+
diagnostic_totals_all: DiagnosticTotals | None = None
|
|
86
110
|
|
|
87
111
|
|
|
88
112
|
# Tiny intent object used by check()/strip() to gate writes
|
|
@@ -64,6 +64,7 @@ class ArgsNamespace(TypedDict, total=False):
|
|
|
64
64
|
|
|
65
65
|
Attributes:
|
|
66
66
|
log_level: Verbosity level for logging.
|
|
67
|
+
verbosity_level: Program-output verbosity (0=terse, 1=verbose).
|
|
67
68
|
no_config: Whether to ignore local config files.
|
|
68
69
|
config_files: List of extra config file paths.
|
|
69
70
|
files: List of file paths to process.
|
|
@@ -80,6 +81,7 @@ class ArgsNamespace(TypedDict, total=False):
|
|
|
80
81
|
|
|
81
82
|
# Global options: retrieve from ctx.obj
|
|
82
83
|
log_level: int
|
|
84
|
+
verbosity_level: int | None
|
|
83
85
|
|
|
84
86
|
# TopMark Command
|
|
85
87
|
# command: TopMarkCommand
|
|
@@ -109,6 +111,7 @@ class ArgsNamespace(TypedDict, total=False):
|
|
|
109
111
|
def build_args_namespace(
|
|
110
112
|
*,
|
|
111
113
|
log_level: int = logging.WARNING,
|
|
114
|
+
verbosity_level: int | None = None,
|
|
112
115
|
# command: TopMarkCommand = TopMarkCommand.NONE,
|
|
113
116
|
no_config: bool | None = None,
|
|
114
117
|
config_files: list[str] | None = None,
|
|
@@ -128,6 +131,7 @@ def build_args_namespace(
|
|
|
128
131
|
|
|
129
132
|
Args:
|
|
130
133
|
log_level: Verbosity level for logging.
|
|
134
|
+
verbosity_level: Program-output verbosity (0=terse, 1=verbose).
|
|
131
135
|
no_config: Whether to ignore local config files.
|
|
132
136
|
config_files: List of extra config file paths.
|
|
133
137
|
files: List of file paths to process.
|
|
@@ -147,6 +151,7 @@ def build_args_namespace(
|
|
|
147
151
|
"""
|
|
148
152
|
return {
|
|
149
153
|
"log_level": log_level,
|
|
154
|
+
"verbosity_level": verbosity_level,
|
|
150
155
|
# "command": command,
|
|
151
156
|
"no_config": no_config,
|
|
152
157
|
"config_files": config_files,
|
|
@@ -175,6 +175,7 @@ def build_config_common(
|
|
|
175
175
|
config_paths=config_paths,
|
|
176
176
|
align_fields=align_fields,
|
|
177
177
|
header_format=header_format,
|
|
178
|
+
verbosity_level=ctx.obj.get("verbosity_level"),
|
|
178
179
|
)
|
|
179
180
|
|
|
180
181
|
|
|
@@ -206,6 +207,7 @@ def build_config_and_file_list(
|
|
|
206
207
|
config_paths=config_paths,
|
|
207
208
|
align_fields=align_fields,
|
|
208
209
|
header_format=header_format,
|
|
210
|
+
verbosity_level=ctx.obj.get("verbosity_level"),
|
|
209
211
|
)
|
|
210
212
|
return config, [Path(plan.paths[0])]
|
|
211
213
|
else:
|
|
@@ -224,5 +226,6 @@ def build_config_and_file_list(
|
|
|
224
226
|
config_paths=config_paths,
|
|
225
227
|
align_fields=align_fields,
|
|
226
228
|
header_format=header_format,
|
|
229
|
+
verbosity_level=ctx.obj.get("verbosity_level"),
|
|
227
230
|
)
|
|
228
231
|
return config, resolve_file_list(config)
|
|
@@ -223,9 +223,6 @@ def check_command(
|
|
|
223
223
|
UNEXPECTED_ERROR (255): An unhandled error occurred.
|
|
224
224
|
"""
|
|
225
225
|
ctx = click.get_current_context()
|
|
226
|
-
level = ctx.obj["log_level"]
|
|
227
|
-
logger.debug(f"{ctx.command.name}: Log level is {level}")
|
|
228
|
-
logger.setLevel(level)
|
|
229
226
|
|
|
230
227
|
fmt: OutputFormat = output_format or OutputFormat.DEFAULT
|
|
231
228
|
if fmt in (OutputFormat.JSON, OutputFormat.NDJSON):
|
|
@@ -207,9 +207,6 @@ def strip_command(
|
|
|
207
207
|
UNEXPECTED_ERROR (255): An unhandled error occurred.
|
|
208
208
|
"""
|
|
209
209
|
ctx = click.get_current_context()
|
|
210
|
-
level = ctx.obj["log_level"]
|
|
211
|
-
logger.debug(f"{ctx.command.name}: Log level is {level}")
|
|
212
|
-
logger.setLevel(level)
|
|
213
210
|
|
|
214
211
|
fmt: OutputFormat = output_format or OutputFormat.DEFAULT
|
|
215
212
|
if fmt in (OutputFormat.JSON, OutputFormat.NDJSON):
|
|
@@ -43,6 +43,7 @@ def resolve_config_from_click(
|
|
|
43
43
|
config_paths: list[str],
|
|
44
44
|
align_fields: bool,
|
|
45
45
|
header_format: HeaderOutputFormat | None,
|
|
46
|
+
verbosity_level: int | None,
|
|
46
47
|
) -> Config:
|
|
47
48
|
"""Build a :class:`Config` from Click parameters.
|
|
48
49
|
|
|
@@ -61,12 +62,15 @@ def resolve_config_from_click(
|
|
|
61
62
|
config_paths: Extra config TOML file paths to merge.
|
|
62
63
|
align_fields: Whether to align header fields with colons.
|
|
63
64
|
header_format: Selected header output format.
|
|
65
|
+
verbosity_level: Program-output verbosity (0=terse, 1=verbose);
|
|
66
|
+
None = inherit from parent context.
|
|
64
67
|
|
|
65
68
|
Returns:
|
|
66
69
|
Config: The merged configuration.
|
|
67
70
|
"""
|
|
68
71
|
args: ArgsNamespace = build_args_namespace(
|
|
69
72
|
log_level=ctx.obj.get("log_level"),
|
|
73
|
+
verbosity_level=verbosity_level,
|
|
70
74
|
files=list(files),
|
|
71
75
|
files_from=list(files_from),
|
|
72
76
|
stdin=stdin,
|
|
@@ -36,6 +36,7 @@ from topmark.cli.options import (
|
|
|
36
36
|
resolve_color_mode,
|
|
37
37
|
resolve_verbosity,
|
|
38
38
|
)
|
|
39
|
+
from topmark.cli_shared.console import Console
|
|
39
40
|
from topmark.config.logging import get_logger, setup_logging
|
|
40
41
|
from topmark.pipeline.processors import register_all_processors
|
|
41
42
|
|
|
@@ -58,6 +59,10 @@ def init_common_state(
|
|
|
58
59
|
"""
|
|
59
60
|
ctx.obj = ctx.obj or {}
|
|
60
61
|
|
|
62
|
+
# Program-output verbosity: map -v/-q to a non-negative level (0..2)
|
|
63
|
+
vlevel = max(0, min(2, verbose - quiet))
|
|
64
|
+
ctx.obj["verbosity_level"] = vlevel
|
|
65
|
+
|
|
61
66
|
# Set the log level
|
|
62
67
|
level = resolve_verbosity(verbose, quiet)
|
|
63
68
|
ctx.obj["log_level"] = level
|
|
@@ -68,6 +73,9 @@ def init_common_state(
|
|
|
68
73
|
ctx.obj["color_enabled"] = enable_color
|
|
69
74
|
ctx.color = enable_color
|
|
70
75
|
|
|
76
|
+
console = Console(enable_color=not no_color)
|
|
77
|
+
ctx.obj["console"] = console
|
|
78
|
+
|
|
71
79
|
|
|
72
80
|
@click.group(
|
|
73
81
|
cls=click.Group,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# topmark:header:start
|
|
2
|
+
#
|
|
3
|
+
# file : console.py
|
|
4
|
+
# file_relpath : src/topmark/cli_shared/console.py
|
|
5
|
+
# project : TopMark
|
|
6
|
+
# license : MIT
|
|
7
|
+
# copyright : (c) 2025 Olivier Biot
|
|
8
|
+
#
|
|
9
|
+
# topmark:header:end
|
|
10
|
+
|
|
11
|
+
"""Console abstraction for user-facing program output.
|
|
12
|
+
|
|
13
|
+
This module provides a `Console` class that separates CLI output from
|
|
14
|
+
internal logging. Use this for messages intended for end users, while
|
|
15
|
+
reserving `logging` for diagnostics.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, TextIO, TypedDict
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# This TypedDict is for documentation and type-checking on the *caller* side.
|
|
25
|
+
class StyleKwargs(TypedDict, total=False):
|
|
26
|
+
"""Keyword arguments accepted by click.style()."""
|
|
27
|
+
|
|
28
|
+
fg: str
|
|
29
|
+
bg: str
|
|
30
|
+
bold: bool
|
|
31
|
+
dim: bool
|
|
32
|
+
underline: bool
|
|
33
|
+
blink: bool
|
|
34
|
+
reverse: bool
|
|
35
|
+
strikethrough: bool
|
|
36
|
+
# Ensure all valid arguments from click.style() are included here
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Console:
|
|
40
|
+
"""Program-output console, independent from the logger.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
enable_color (bool): Whether to emit ANSI color codes.
|
|
44
|
+
out (TextIO): Stream for standard output (defaults to sys.stdout).
|
|
45
|
+
err (TextIO): Stream for error output (defaults to sys.stderr).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self, *, enable_color: bool = True, out: TextIO | None = None, err: TextIO | None = None
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initializes the Console.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
enable_color: If True, enables ANSI color codes in the output.
|
|
55
|
+
Otherwise, all output is plain text.
|
|
56
|
+
out: The text stream to use for standard output.
|
|
57
|
+
Defaults to `sys.stdout`.
|
|
58
|
+
err: The text stream to use for error output.
|
|
59
|
+
Defaults to `sys.stderr`.
|
|
60
|
+
"""
|
|
61
|
+
self.enable_color = enable_color
|
|
62
|
+
self.out = out or sys.stdout
|
|
63
|
+
self.err = err or sys.stderr
|
|
64
|
+
|
|
65
|
+
def print(self, text: str = "", *, nl: bool = True) -> None:
|
|
66
|
+
"""Write a message to stdout.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
text: Message text.
|
|
70
|
+
nl: If True, append a newline.
|
|
71
|
+
"""
|
|
72
|
+
click.echo(text, nl=nl, file=self.out, color=self.enable_color)
|
|
73
|
+
|
|
74
|
+
def warn(self, text: str, *, nl: bool = True) -> None:
|
|
75
|
+
"""Write a warning message to stderr.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
text: Warning text.
|
|
79
|
+
nl: If True, append a newline.
|
|
80
|
+
"""
|
|
81
|
+
click.echo(text, nl=nl, file=self.err, color=self.enable_color)
|
|
82
|
+
|
|
83
|
+
def error(self, text: str, *, nl: bool = True) -> None:
|
|
84
|
+
"""Write an error message to stderr.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
text: Error text.
|
|
88
|
+
nl: If True, append a newline.
|
|
89
|
+
"""
|
|
90
|
+
click.echo(text, nl=nl, file=self.err, color=self.enable_color)
|
|
91
|
+
|
|
92
|
+
def styled(self, text: str, **style_kwargs: Any) -> str:
|
|
93
|
+
"""Return a styled string using click.style.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
text: Text to style.
|
|
97
|
+
**style_kwargs: Subset of keyword arguments supported by click.style.
|
|
98
|
+
Expected keys are defined in the StyleKwargs TypedDict.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
str: The styled text (or plain text if color is disabled).
|
|
102
|
+
"""
|
|
103
|
+
if not self.enable_color:
|
|
104
|
+
return text
|
|
105
|
+
return click.style(text, **style_kwargs)
|
|
@@ -235,7 +235,7 @@ def classify_outcome(r: ProcessingContext) -> tuple[str, str, Callable[[str], st
|
|
|
235
235
|
logger.debug("status: %s", r.status)
|
|
236
236
|
|
|
237
237
|
if r.status.file is not FileStatus.RESOLVED:
|
|
238
|
-
return (f"file:{r.status.file.name}", f"
|
|
238
|
+
return (f"file:{r.status.file.name}", f"{r.status.file.value}", r.status.file.color)
|
|
239
239
|
|
|
240
240
|
# Highest precedence: if comparison says UNCHANGED, treat as compliant
|
|
241
241
|
if r.status.comparison is ComparisonStatus.UNCHANGED:
|
|
@@ -67,6 +67,7 @@ class Config:
|
|
|
67
67
|
files_from (list[str]): Paths to files that list newline-delimited candidate file paths
|
|
68
68
|
to add before filtering.
|
|
69
69
|
file_types (list[str]): File extensions or types to process.
|
|
70
|
+
verbosity_level (int | None): None = inherit, 0 = terse, 1 = verbose diagnostics.
|
|
70
71
|
"""
|
|
71
72
|
|
|
72
73
|
# Initialization timestamp for the config instance
|
|
@@ -75,6 +76,10 @@ class Config:
|
|
|
75
76
|
# Paths or identifiers of configuration files or sources used to build this Config
|
|
76
77
|
config_files: list[Path | str] = field(default_factory=lambda: [])
|
|
77
78
|
|
|
79
|
+
# Verbosity level
|
|
80
|
+
# verbosity_level (int | None): None = inherit, 0 = terse, 1 = verbose diagnostics
|
|
81
|
+
verbosity_level: int | None = None
|
|
82
|
+
|
|
78
83
|
# Header configuration fields and their values
|
|
79
84
|
header_fields: list[str] = field(default_factory=lambda: []) # From [header].fields
|
|
80
85
|
field_values: dict[str, str] = field(default_factory=lambda: {}) # From [fields]
|
|
@@ -359,6 +364,19 @@ class Config:
|
|
|
359
364
|
): # Only override if align_fields was passed via CLI
|
|
360
365
|
self.align_fields = args["align_fields"]
|
|
361
366
|
|
|
367
|
+
if "verbosity_level" in args and args["verbosity_level"] is not None:
|
|
368
|
+
try:
|
|
369
|
+
self.verbosity_level = int(args["verbosity_level"])
|
|
370
|
+
except (TypeError, ValueError):
|
|
371
|
+
logger.warning(
|
|
372
|
+
"Invalid verbosity_level=%r (expected int); keeping %r",
|
|
373
|
+
args["verbosity_level"],
|
|
374
|
+
self.verbosity_level,
|
|
375
|
+
)
|
|
376
|
+
elif "verbosity_level" in args and args["verbosity_level"] is None:
|
|
377
|
+
# Explicit None means inherit, do not override
|
|
378
|
+
pass
|
|
379
|
+
|
|
362
380
|
logger.debug("Patched Config: %s", self)
|
|
363
381
|
logger.info("Applied CLI overrides to Config")
|
|
364
382
|
logger.debug("apply_cli_args(): finalized stdin=%s files=%s", self.stdin, self.files)
|
|
@@ -511,6 +529,9 @@ class Config:
|
|
|
511
529
|
relative_to=(other.relative_to if other.relative_to is not None else self.relative_to),
|
|
512
530
|
# Use other's file_types if set, else self's
|
|
513
531
|
file_types=other.file_types or self.file_types,
|
|
532
|
+
verbosity_level=(
|
|
533
|
+
other.verbosity_level if other.verbosity_level is not None else self.verbosity_level
|
|
534
|
+
),
|
|
514
535
|
)
|
|
515
536
|
|
|
516
537
|
def to_toml_dict(self) -> dict[str, Any]:
|
|
@@ -263,6 +263,47 @@ class WriteStatus(BaseStatus):
|
|
|
263
263
|
)
|
|
264
264
|
|
|
265
265
|
|
|
266
|
+
# --- Diagnostics support ------------------------------------------------------
|
|
267
|
+
class DiagnosticLevel(Enum):
|
|
268
|
+
"""Severity levels for diagnostics collected during processing.
|
|
269
|
+
|
|
270
|
+
Levels map to terminal colors and are ordered by importance: ERROR > WARNING > INFO.
|
|
271
|
+
This enum is **internal**; the public API exposes string literals.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
INFO = "info"
|
|
275
|
+
WARNING = "warning"
|
|
276
|
+
ERROR = "error"
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def color(self) -> Callable[[str], str]:
|
|
280
|
+
"""Return the `yachalk` color function associated with this severity level.
|
|
281
|
+
|
|
282
|
+
Intended for human-readable output only; machine formats should not use colors.
|
|
283
|
+
"""
|
|
284
|
+
return cast(
|
|
285
|
+
"Callable[[str], str]",
|
|
286
|
+
{
|
|
287
|
+
DiagnosticLevel.INFO: chalk.blue,
|
|
288
|
+
DiagnosticLevel.WARNING: chalk.yellow,
|
|
289
|
+
DiagnosticLevel.ERROR: chalk.red_bright,
|
|
290
|
+
}[self],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class Diagnostic:
|
|
296
|
+
"""Internal structured diagnostic with a severity level and message.
|
|
297
|
+
|
|
298
|
+
Note:
|
|
299
|
+
This type is **not** part of the public API surface. Conversions to
|
|
300
|
+
`PublicDiagnostic` happen at the API boundary.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
level: DiagnosticLevel
|
|
304
|
+
message: str
|
|
305
|
+
|
|
306
|
+
|
|
266
307
|
@dataclass
|
|
267
308
|
class HeaderProcessingStatus:
|
|
268
309
|
"""Tracks the status of each processing phase for a single file.
|
|
@@ -349,7 +390,7 @@ class ProcessingContext:
|
|
|
349
390
|
header_diff: str | None = None # Unified diff (patch) for patching (updating) the header
|
|
350
391
|
|
|
351
392
|
# Processing diagnostics: warnings/errors collected during processing
|
|
352
|
-
diagnostics: list[
|
|
393
|
+
diagnostics: list[Diagnostic] = field(default_factory=list[Diagnostic])
|
|
353
394
|
|
|
354
395
|
def to_dict(self) -> dict[str, object]:
|
|
355
396
|
"""Return a machine-readable representation of this processing result.
|
|
@@ -369,7 +410,14 @@ class ProcessingContext:
|
|
|
369
410
|
},
|
|
370
411
|
"existing_header_range": self.existing_header_range,
|
|
371
412
|
"has_diff": bool(self.header_diff),
|
|
372
|
-
"diagnostics":
|
|
413
|
+
"diagnostics": [
|
|
414
|
+
{"level": d.level.value, "message": d.message} for d in self.diagnostics
|
|
415
|
+
],
|
|
416
|
+
"diagnostic_counts": {
|
|
417
|
+
"info": sum(1 for d in self.diagnostics if d.level is DiagnosticLevel.INFO),
|
|
418
|
+
"warning": sum(1 for d in self.diagnostics if d.level is DiagnosticLevel.WARNING),
|
|
419
|
+
"error": sum(1 for d in self.diagnostics if d.level is DiagnosticLevel.ERROR),
|
|
420
|
+
},
|
|
373
421
|
# Heuristic: treat MISSING or CHANGED as a would-change indicator
|
|
374
422
|
"would_change": (
|
|
375
423
|
self.status.header is HeaderStatus.MISSING
|
|
@@ -377,8 +425,8 @@ class ProcessingContext:
|
|
|
377
425
|
),
|
|
378
426
|
}
|
|
379
427
|
|
|
380
|
-
def
|
|
381
|
-
"""Return the current human-readable per-file summary string.
|
|
428
|
+
def format_summary_v0_3_2(self) -> str:
|
|
429
|
+
"""OLD - Return the current human-readable per-file summary string.
|
|
382
430
|
|
|
383
431
|
TODO: refine the rendering.
|
|
384
432
|
"""
|
|
@@ -394,12 +442,119 @@ class ProcessingContext:
|
|
|
394
442
|
f"{chalk.dim(self.file_type.name)} - "
|
|
395
443
|
f"{self.status.file.color('file ' + self.status.file.value)}"
|
|
396
444
|
)
|
|
397
|
-
result += f", {self.status.header.color('
|
|
445
|
+
result += f", {self.status.header.color('header: ' + self.status.header.value)}"
|
|
398
446
|
result += f", {self.status.generation.color('new header: ' + self.status.generation.value)}"
|
|
399
447
|
result += f", {self.status.comparison.color('result: ' + self.status.comparison.value)}"
|
|
400
448
|
result += f", {self.status.strip.color('strip: ' + self.status.strip.value)}"
|
|
401
449
|
return result
|
|
402
450
|
|
|
451
|
+
def format_summary_v0_3_3_dev0(self) -> str:
|
|
452
|
+
"""Return the current human-readable per-file summary string.
|
|
453
|
+
|
|
454
|
+
TODO: refine the rendering.
|
|
455
|
+
"""
|
|
456
|
+
result: str = f"{self.path}: "
|
|
457
|
+
if not self.file_type or self.status.file != FileStatus.RESOLVED:
|
|
458
|
+
result += (
|
|
459
|
+
f"{chalk.dim(self.file_type.name if self.file_type else '<unknown>')} - "
|
|
460
|
+
f"{self.status.file.color('file ' + self.status.file.value)}"
|
|
461
|
+
)
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
result += f"{chalk.dim(self.file_type.name)}"
|
|
465
|
+
result += f" - {self.status.header.color('header ' + self.status.header.value)}"
|
|
466
|
+
result += f" - {self.status.comparison.color(self.status.comparison.value)}"
|
|
467
|
+
if self.status.write != WriteStatus.PENDING:
|
|
468
|
+
result += f" - {self.status.write.color(self.status.write.value)}"
|
|
469
|
+
elif self.status.generation != GenerationStatus.PENDING:
|
|
470
|
+
result += f" - {self.status.generation.color(self.status.generation.value)}"
|
|
471
|
+
else:
|
|
472
|
+
f"{self.status.file.color('file ' + self.status.file.value)}"
|
|
473
|
+
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
def format_summary(self) -> str:
|
|
477
|
+
"""Return a concise, human‑readable one‑liner for this file.
|
|
478
|
+
|
|
479
|
+
The summary is aligned with TopMark's pipeline phases and mirrors what
|
|
480
|
+
comparable tools (e.g., *ruff*, *black*, *prettier*) surface: a clear
|
|
481
|
+
primary outcome plus a few terse hints.
|
|
482
|
+
|
|
483
|
+
Rendering rules:
|
|
484
|
+
1) Primary bucket comes from :func:`topmark.cli_shared.utils.classify_outcome`.
|
|
485
|
+
This ensures stable wording across commands/pipelines.
|
|
486
|
+
2) If a write outcome is known (e.g., PREVIEWED/WRITTEN/INSERTED/REMOVED),
|
|
487
|
+
append it as a trailing hint.
|
|
488
|
+
3) If there is a diff but no write outcome (e.g., check/summary with
|
|
489
|
+
`--diff`), append a "diff" hint.
|
|
490
|
+
4) If diagnostics exist, append the diagnostic count as a hint.
|
|
491
|
+
|
|
492
|
+
Verbose per‑line diagnostics are emitted only when Config.verbosity_level >= 1
|
|
493
|
+
(treats None as 0).
|
|
494
|
+
|
|
495
|
+
Examples (colors omitted here):
|
|
496
|
+
path/to/file.py: python – would insert header - previewed
|
|
497
|
+
path/to/file.py: python – up-to-date
|
|
498
|
+
path/to/file.py: python – would strip header - diff - 2 issues
|
|
499
|
+
"""
|
|
500
|
+
# Local import to avoid import cycles at module import time
|
|
501
|
+
from topmark.cli_shared.utils import classify_outcome
|
|
502
|
+
|
|
503
|
+
parts: list[str] = [f"{self.path}:"]
|
|
504
|
+
|
|
505
|
+
# File type (dim), or <unknown> if resolution failed
|
|
506
|
+
if self.file_type is not None:
|
|
507
|
+
parts.append(chalk.dim(self.file_type.name))
|
|
508
|
+
else:
|
|
509
|
+
parts.append(chalk.dim("<unknown>"))
|
|
510
|
+
|
|
511
|
+
# Primary bucket/label with color
|
|
512
|
+
key, label, color_fn = classify_outcome(self)
|
|
513
|
+
parts.append("\u2013") # en dash separator
|
|
514
|
+
parts.append(color_fn(label))
|
|
515
|
+
|
|
516
|
+
if key != "ok":
|
|
517
|
+
# Secondary hints: write status > diff marker > diagnostics
|
|
518
|
+
|
|
519
|
+
if self.status.write is not WriteStatus.PENDING:
|
|
520
|
+
parts.append("-")
|
|
521
|
+
parts.append(self.status.write.color(self.status.write.value))
|
|
522
|
+
elif self.header_diff:
|
|
523
|
+
parts.append("-")
|
|
524
|
+
parts.append(chalk.yellow("diff"))
|
|
525
|
+
|
|
526
|
+
if self.diagnostics:
|
|
527
|
+
n_info = sum(1 for d in self.diagnostics if d.level is DiagnosticLevel.INFO)
|
|
528
|
+
n_warn = sum(1 for d in self.diagnostics if d.level is DiagnosticLevel.WARNING)
|
|
529
|
+
n_err = sum(1 for d in self.diagnostics if d.level is DiagnosticLevel.ERROR)
|
|
530
|
+
parts.append("-")
|
|
531
|
+
# Compose a compact triage summary like "1 error, 2 warnings"
|
|
532
|
+
triage: list[str] = []
|
|
533
|
+
if n_err:
|
|
534
|
+
triage.append(chalk.red_bright(f"{n_err} error" + ("s" if n_err != 1 else "")))
|
|
535
|
+
if n_warn:
|
|
536
|
+
triage.append(chalk.yellow(f"{n_warn} warning" + ("s" if n_warn != 1 else "")))
|
|
537
|
+
if n_info and not (n_err or n_warn):
|
|
538
|
+
# Only show infos when there are no higher severities
|
|
539
|
+
triage.append(chalk.blue(f"{n_info} info" + ("s" if n_info != 1 else "")))
|
|
540
|
+
parts.append(", ".join(triage) if triage else chalk.blue("info"))
|
|
541
|
+
|
|
542
|
+
result: str = " ".join(parts)
|
|
543
|
+
|
|
544
|
+
# Optional verbose listing (kept simple; could be gated by verbosity later)
|
|
545
|
+
if self.diagnostics and (self.config.verbosity_level or 0) >= 1:
|
|
546
|
+
details: list[str] = []
|
|
547
|
+
for d in self.diagnostics:
|
|
548
|
+
prefix = {
|
|
549
|
+
DiagnosticLevel.ERROR: chalk.red_bright("error"),
|
|
550
|
+
DiagnosticLevel.WARNING: chalk.yellow("warning"),
|
|
551
|
+
DiagnosticLevel.INFO: chalk.blue("info"),
|
|
552
|
+
}[d.level]
|
|
553
|
+
details.append(f" - [{prefix}] {d.message}")
|
|
554
|
+
result += "\n" + "\n".join(details)
|
|
555
|
+
|
|
556
|
+
return result
|
|
557
|
+
|
|
403
558
|
@property
|
|
404
559
|
def summary(self) -> str:
|
|
405
560
|
"""Return a formatted summary string of the processing status for this file."""
|
|
@@ -409,3 +564,28 @@ class ProcessingContext:
|
|
|
409
564
|
def bootstrap(cls, *, path: Path, config: Config) -> ProcessingContext:
|
|
410
565
|
"""Create a fresh context with no derived state."""
|
|
411
566
|
return cls(path=path, config=config)
|
|
567
|
+
|
|
568
|
+
# --- Convenience helpers -------------------------------------------------
|
|
569
|
+
def add_info(self, message: str) -> None:
|
|
570
|
+
"""Add an `info` diagnostic to the processing context.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
message (str): The diagnostic message.
|
|
574
|
+
"""
|
|
575
|
+
self.diagnostics.append(Diagnostic(DiagnosticLevel.INFO, message))
|
|
576
|
+
|
|
577
|
+
def add_warning(self, message: str) -> None:
|
|
578
|
+
"""Add an `warning` diagnostic to the processing context.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
message (str): The diagnostic message.
|
|
582
|
+
"""
|
|
583
|
+
self.diagnostics.append(Diagnostic(DiagnosticLevel.WARNING, message))
|
|
584
|
+
|
|
585
|
+
def add_error(self, message: str) -> None:
|
|
586
|
+
"""Add an `error` diagnostic to the processing context.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
message (str): The diagnostic message.
|
|
590
|
+
"""
|
|
591
|
+
self.diagnostics.append(Diagnostic(DiagnosticLevel.ERROR, message))
|
|
@@ -19,8 +19,6 @@ from __future__ import annotations
|
|
|
19
19
|
|
|
20
20
|
from typing import TYPE_CHECKING, Final, Mapping, Tuple
|
|
21
21
|
|
|
22
|
-
from topmark.pipeline.steps import stripper
|
|
23
|
-
|
|
24
22
|
from .steps import (
|
|
25
23
|
builder,
|
|
26
24
|
comparer,
|
|
@@ -29,6 +27,7 @@ from .steps import (
|
|
|
29
27
|
renderer,
|
|
30
28
|
resolver,
|
|
31
29
|
scanner,
|
|
30
|
+
stripper,
|
|
32
31
|
updater,
|
|
33
32
|
)
|
|
34
33
|
|
|
@@ -168,9 +168,7 @@ class HeaderProcessor:
|
|
|
168
168
|
if start_rel is None or end_rel is None or end_rel <= start_rel:
|
|
169
169
|
# Keep scanner as the single authority for header MALFORMED status.
|
|
170
170
|
# Here we just surface a diagnostic to aid debugging.
|
|
171
|
-
context.
|
|
172
|
-
f"parse_fields(): could not locate a valid START/END marker pair in {context.path}"
|
|
173
|
-
)
|
|
171
|
+
context.add_error("parse_fields(): could not locate a valid START/END marker pair.")
|
|
174
172
|
return {}
|
|
175
173
|
|
|
176
174
|
# 2) Extract payload (strictly between markers).
|
|
@@ -193,13 +191,13 @@ class HeaderProcessor:
|
|
|
193
191
|
continue
|
|
194
192
|
|
|
195
193
|
if ":" not in cleaned:
|
|
196
|
-
context.
|
|
197
|
-
f"Unrecognized header
|
|
194
|
+
context.add_error(
|
|
195
|
+
f"Unrecognized header field name at line {abs_line_no}: '{raw.rstrip()}'"
|
|
198
196
|
)
|
|
199
197
|
continue
|
|
200
198
|
|
|
201
199
|
key, value = cleaned.split(":", 1)
|
|
202
|
-
k = key.strip()
|
|
200
|
+
k = key.strip()
|
|
203
201
|
v = value.strip()
|
|
204
202
|
if not k:
|
|
205
203
|
logger.warning(
|
|
@@ -207,8 +205,8 @@ class HeaderProcessor:
|
|
|
207
205
|
abs_line_no,
|
|
208
206
|
raw,
|
|
209
207
|
)
|
|
210
|
-
context.
|
|
211
|
-
f"Empty header
|
|
208
|
+
context.add_error(
|
|
209
|
+
f"Empty header field name at line {abs_line_no}: '{raw.rstrip()}'"
|
|
212
210
|
)
|
|
213
211
|
continue
|
|
214
212
|
result[k] = v
|
|
@@ -97,9 +97,7 @@ def build(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
97
97
|
"Config.field_values contains keys that overlap with builtin fields: %s",
|
|
98
98
|
builtin_overlap_repr,
|
|
99
99
|
)
|
|
100
|
-
ctx.
|
|
101
|
-
f"Redefined built-in fields: {builtin_overlap_repr}"
|
|
102
|
-
]
|
|
100
|
+
ctx.add_warning(f"Redefined built-in fields: {builtin_overlap_repr}")
|
|
103
101
|
|
|
104
102
|
# Merge built-in and configuration-defined fields
|
|
105
103
|
# Allow user-defined fields to override built-ins when both are in config.field_values
|
|
@@ -113,7 +111,7 @@ def build(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
113
111
|
value = all_fields.get(key)
|
|
114
112
|
if value is None:
|
|
115
113
|
logger.warning("Unknown header field: %s", key)
|
|
116
|
-
ctx.
|
|
114
|
+
ctx.add_error(f"Unknown header field: {key}")
|
|
117
115
|
else:
|
|
118
116
|
result[key] = value
|
|
119
117
|
|
|
@@ -62,6 +62,7 @@ def read(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
62
62
|
|
|
63
63
|
if ctx.path.stat().st_size == 0:
|
|
64
64
|
logger.warning("Found empty file: %s", ctx.path)
|
|
65
|
+
ctx.add_warning("File is empty.")
|
|
65
66
|
ctx.status.file = FileStatus.EMPTY_FILE
|
|
66
67
|
return ctx
|
|
67
68
|
|
|
@@ -122,7 +123,7 @@ def read(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
122
123
|
except Exception as e: # Log and attach diagnostic; continue without raising
|
|
123
124
|
ctx.status.file = FileStatus.UNREADABLE
|
|
124
125
|
logger.error("Error reading file %s: %s", ctx.path, e)
|
|
125
|
-
ctx.
|
|
126
|
+
ctx.add_error(f"Error reading file: {e}")
|
|
126
127
|
|
|
127
128
|
if nl:
|
|
128
129
|
# Store the line end style
|
|
@@ -160,7 +161,7 @@ def read(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
160
161
|
# shebang, skip processing by default and surface a clear diagnostic.
|
|
161
162
|
policy = getattr(ctx.header_processor.file_type, "header_policy", None)
|
|
162
163
|
if ctx.leading_bom and ctx.has_shebang and policy and policy.supports_shebang:
|
|
163
|
-
ctx.
|
|
164
|
+
ctx.add_error(
|
|
164
165
|
"UTF-8 BOM appears before the shebang; POSIX requires '#!' at byte 0. "
|
|
165
166
|
"TopMark will not modify this file by default. Consider removing the BOM "
|
|
166
167
|
"or using a future '--fix-bom' option to resolve this conflict."
|
|
@@ -176,6 +177,7 @@ def read(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
176
177
|
# Record whether the file ends with a newline (used when generating patches)
|
|
177
178
|
if len(lines) == 0:
|
|
178
179
|
logger.warning("File has no lines (empty): %s", ctx.path)
|
|
180
|
+
ctx.add_warning("File is empty.")
|
|
179
181
|
ctx.status.file = FileStatus.EMPTY_FILE
|
|
180
182
|
return ctx
|
|
181
183
|
|
|
@@ -204,7 +206,7 @@ def read(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
204
206
|
except Exception as e: # Log and attach diagnostic; continue without raising
|
|
205
207
|
ctx.status.file = FileStatus.UNREADABLE
|
|
206
208
|
logger.error("Error reading file %s: %s", ctx.path, e)
|
|
207
|
-
ctx.
|
|
209
|
+
ctx.add_error(f"Error reading file: {e}")
|
|
208
210
|
|
|
209
211
|
logger.warning("%s: File cannot be processed: %s", ctx.status.file.value, ctx.path)
|
|
210
212
|
return ctx
|
|
@@ -250,7 +250,7 @@ def resolve(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
250
250
|
|
|
251
251
|
if file_type.skip_processing:
|
|
252
252
|
ctx.status.file = FileStatus.SKIPPED_KNOWN_NO_HEADERS
|
|
253
|
-
ctx.
|
|
253
|
+
ctx.add_warning(
|
|
254
254
|
f"File type '{file_type.name}' recognized; "
|
|
255
255
|
"headers are not supported for this format. Skipping."
|
|
256
256
|
)
|
|
@@ -267,9 +267,7 @@ def resolve(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
267
267
|
ctx.status.file = (
|
|
268
268
|
FileStatus.SKIPPED_NO_HEADER_PROCESSOR
|
|
269
269
|
) # or SKIPPED_NO_HEADER_MANAGER if that's your enum
|
|
270
|
-
ctx.
|
|
271
|
-
f"No header processor registered for file type '{file_type.name}'."
|
|
272
|
-
)
|
|
270
|
+
ctx.add_warning(f"No header processor registered for file type '{file_type.name}'.")
|
|
273
271
|
logger.info(
|
|
274
272
|
"No header processor registered for file type '%s' (file '%s')",
|
|
275
273
|
file_type.name,
|
|
@@ -290,6 +288,6 @@ def resolve(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
290
288
|
|
|
291
289
|
# No FileType matched: mark as unsupported and record a diagnostic
|
|
292
290
|
ctx.status.file = FileStatus.SKIPPED_UNSUPPORTED
|
|
293
|
-
ctx.
|
|
291
|
+
ctx.add_warning("No file type associated with this file.")
|
|
294
292
|
logger.info("Unsupported file type for '%s' (no matcher)", ctx.path)
|
|
295
293
|
return ctx
|
|
@@ -44,9 +44,6 @@ def strip(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
44
44
|
if ctx.status.header not in [HeaderStatus.EMPTY, HeaderStatus.DETECTED]:
|
|
45
45
|
# No header to be stripped
|
|
46
46
|
ctx.status.strip = StripStatus.FAILED
|
|
47
|
-
ctx.diagnostics.append(
|
|
48
|
-
f"stripper: skipping {ctx.path}, header status is {ctx.status.header.value}"
|
|
49
|
-
)
|
|
50
47
|
return ctx
|
|
51
48
|
|
|
52
49
|
if ctx.header_processor is None:
|
|
@@ -70,7 +67,5 @@ def strip(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
70
67
|
ctx.updated_file_lines = new_lines
|
|
71
68
|
# A header was present and removed
|
|
72
69
|
ctx.status.strip = StripStatus.READY
|
|
73
|
-
|
|
74
|
-
f"stripper: removed header lines at span {removed[0]}..{removed[1]} in {ctx.path}"
|
|
75
|
-
)
|
|
70
|
+
logger.debug(f"stripper: removed header lines at span {removed[0]}..{removed[1]}.")
|
|
76
71
|
return ctx
|
|
@@ -64,7 +64,7 @@ def _prepend_bom_to_lines_if_needed(lines: list[str], ctx: ProcessingContext) ->
|
|
|
64
64
|
if ctx.has_shebang:
|
|
65
65
|
# Do not re-add the BOM which was stripped in reader.read(); a valid shebang
|
|
66
66
|
# must start at byte 0 on POSIX systems.
|
|
67
|
-
ctx.
|
|
67
|
+
ctx.add_error(
|
|
68
68
|
"UTF-8 BOM appears before the shebang; POSIX requires '#!' at byte 0. "
|
|
69
69
|
"TopMark will not modify this file by default. Consider removing the BOM "
|
|
70
70
|
"or using a future '--fix-bom' option to resolve this conflict."
|
|
@@ -118,12 +118,12 @@ def update(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
118
118
|
|
|
119
119
|
# Non-strip processing
|
|
120
120
|
if ctx.expected_header_lines is None:
|
|
121
|
-
ctx.
|
|
121
|
+
ctx.add_error("Cannot update header: no rendered header available")
|
|
122
122
|
ctx.status.write = WriteStatus.SKIPPED
|
|
123
123
|
return ctx
|
|
124
124
|
|
|
125
125
|
if ctx.header_processor is None:
|
|
126
|
-
ctx.
|
|
126
|
+
ctx.add_error("Cannot update header: no header processor assigned")
|
|
127
127
|
ctx.status.write = WriteStatus.SKIPPED
|
|
128
128
|
return ctx
|
|
129
129
|
|
|
@@ -186,7 +186,7 @@ def update(ctx: ProcessingContext) -> ProcessingContext:
|
|
|
186
186
|
insert_index = ctx.header_processor.get_header_insertion_index(original_lines)
|
|
187
187
|
if insert_index == NO_LINE_ANCHOR:
|
|
188
188
|
ctx.status.write = WriteStatus.FAILED
|
|
189
|
-
ctx.
|
|
189
|
+
ctx.add_error(f"No line-based insertion anchor for file: {ctx.path}")
|
|
190
190
|
return ctx
|
|
191
191
|
|
|
192
192
|
# defensive clamp
|
|
@@ -33,6 +33,7 @@ src/topmark/cli/commands/show_defaults.py
|
|
|
33
33
|
src/topmark/cli/commands/strip.py
|
|
34
34
|
src/topmark/cli/commands/version.py
|
|
35
35
|
src/topmark/cli_shared/__init__.py
|
|
36
|
+
src/topmark/cli_shared/console.py
|
|
36
37
|
src/topmark/cli_shared/exit_codes.py
|
|
37
38
|
src/topmark/cli_shared/utils.py
|
|
38
39
|
src/topmark/config/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|