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.
Files changed (77) hide show
  1. {topmark-0.3.2/src/topmark.egg-info → topmark-0.4.0}/PKG-INFO +1 -1
  2. {topmark-0.3.2 → topmark-0.4.0}/pyproject.toml +1 -10
  3. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/api/__init__.py +50 -13
  4. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/api/public_types.py +24 -1
  5. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/api/types.py +27 -3
  6. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/cli_types.py +5 -0
  7. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/cmd_common.py +3 -0
  8. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/check.py +0 -3
  9. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/strip.py +0 -3
  10. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/config_resolver.py +4 -0
  11. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/main.py +8 -0
  12. topmark-0.4.0/src/topmark/cli_shared/console.py +105 -0
  13. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli_shared/utils.py +1 -1
  14. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/config/__init__.py +21 -0
  15. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/context.py +185 -5
  16. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/pipelines.py +1 -2
  17. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/base.py +6 -8
  18. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/builder.py +2 -4
  19. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/reader.py +5 -3
  20. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/resolver.py +3 -5
  21. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/stripper.py +1 -6
  22. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/updater.py +4 -4
  23. {topmark-0.3.2 → topmark-0.4.0/src/topmark.egg-info}/PKG-INFO +1 -1
  24. {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/SOURCES.txt +1 -0
  25. {topmark-0.3.2 → topmark-0.4.0}/LICENSE +0 -0
  26. {topmark-0.3.2 → topmark-0.4.0}/README.md +0 -0
  27. {topmark-0.3.2 → topmark-0.4.0}/setup.cfg +0 -0
  28. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/__init__.py +0 -0
  29. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/__init__.py +0 -0
  30. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/__init__.py +0 -0
  31. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/dump_config.py +0 -0
  32. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/filetypes.py +0 -0
  33. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/init_config.py +0 -0
  34. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/processors.py +0 -0
  35. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/show_defaults.py +0 -0
  36. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/commands/version.py +0 -0
  37. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/errors.py +0 -0
  38. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/io.py +0 -0
  39. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/options.py +0 -0
  40. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli/utils.py +0 -0
  41. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli_shared/__init__.py +0 -0
  42. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/cli_shared/exit_codes.py +0 -0
  43. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/config/logging.py +0 -0
  44. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/config/topmark-default.toml +0 -0
  45. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/constants.py +0 -0
  46. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/file_resolver.py +0 -0
  47. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/__init__.py +0 -0
  48. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/base.py +0 -0
  49. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/instances.py +0 -0
  50. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/policy.py +0 -0
  51. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/filetypes/registry.py +0 -0
  52. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/__init__.py +0 -0
  53. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/contracts.py +0 -0
  54. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/__init__.py +0 -0
  55. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/pound.py +0 -0
  56. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/slash.py +0 -0
  57. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/processors/xml.py +0 -0
  58. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/runner.py +0 -0
  59. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/__init__.py +0 -0
  60. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/comparer.py +0 -0
  61. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/patcher.py +0 -0
  62. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/renderer.py +0 -0
  63. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/pipeline/steps/scanner.py +0 -0
  64. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/py.typed +0 -0
  65. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/__init__.py +0 -0
  66. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/filetypes.py +0 -0
  67. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/processors.py +0 -0
  68. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/registry/registry.py +0 -0
  69. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/rendering/api.py +0 -0
  70. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/rendering/formats.py +0 -0
  71. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/utils/__init__.py +0 -0
  72. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/utils/diff.py +0 -0
  73. {topmark-0.3.2 → topmark-0.4.0}/src/topmark/utils/file.py +0 -0
  74. {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/dependency_links.txt +0 -0
  75. {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/entry_points.txt +0 -0
  76. {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/requires.txt +0 -0
  77. {topmark-0.3.2 → topmark-0.4.0}/src/topmark.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: topmark
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A Python CLI to inspect, validate, and manage license and copyright headers.
5
5
  Author-email: Olivier Biot <olivier.biot@gmail.com>
6
6
  License-Expression: MIT
@@ -10,7 +10,7 @@
10
10
 
11
11
  [project]
12
12
  name = "topmark"
13
- version = "0.3.2"
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 ComparisonStatus, FileStatus, ProcessingContext, WriteStatus
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 .types import FileResult, FileTypeInfo, Outcome, ProcessorInfo, RunResult
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(results: list[ProcessingContext]) -> dict[str, list[str]]:
204
- """Collect per-file diagnostics as a mapping `{path: [messages...]}`.
205
-
206
- Only files that produced diagnostics are included in the result.
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
- Returns:
212
- A mapping from file path (string) to a list of diagnostic messages.
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[str]] = {}
219
+ diags: dict[str, list[PublicDiagnostic]] = {}
215
220
  for r in results:
216
221
  if r.diagnostics:
217
- diags[str(r.path)] = r.diagnostics
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 (string) to a list of pipeline
76
- diagnostic messages; present only if any were produced.
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[str]] | None = None
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"file {r.status.file.value}", r.status.file.color)
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[str] = field(default_factory=lambda: [])
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": list(self.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 format_summary(self) -> str:
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('current header: ' + self.status.header.value)}"
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.diagnostics.append(
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.diagnostics.append(
197
- f"Unrecognized header line at {context.path}:{abs_line_no}: '{raw.rstrip()}'"
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().lstrip("#") # tolerate accidental leading '#'
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.diagnostics.append(
211
- f"Empty header key at {context.path}:{abs_line_no}: '{raw.rstrip()}'"
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.diagnostics = (ctx.diagnostics or []) + [
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.diagnostics.append(f"Unknown header field: {key}")
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.diagnostics.append(f"Error reading file: {e}")
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.diagnostics.append(
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.diagnostics.append(f"Error reading file: {e}")
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.diagnostics.append(
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.diagnostics.append(
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.diagnostics.append("No file type associated with this file.")
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
- ctx.diagnostics.append(
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.diagnostics.append(
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.diagnostics.append("Cannot update header: no rendered header available")
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.diagnostics.append("Cannot update header: no header processor assigned")
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.diagnostics.append(f"No line-based insertion anchor for file: {ctx.path}")
189
+ ctx.add_error(f"No line-based insertion anchor for file: {ctx.path}")
190
190
  return ctx
191
191
 
192
192
  # defensive clamp
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: topmark
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A Python CLI to inspect, validate, and manage license and copyright headers.
5
5
  Author-email: Olivier Biot <olivier.biot@gmail.com>
6
6
  License-Expression: MIT
@@ -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