topmark 0.3.2__tar.gz → 0.5.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 (82) hide show
  1. {topmark-0.3.2/src/topmark.egg-info → topmark-0.5.0}/PKG-INFO +19 -12
  2. {topmark-0.3.2 → topmark-0.5.0}/README.md +18 -11
  3. {topmark-0.3.2 → topmark-0.5.0}/pyproject.toml +1 -10
  4. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/api/__init__.py +59 -20
  5. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/api/public_types.py +24 -1
  6. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/api/types.py +27 -3
  7. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/cli_types.py +12 -10
  8. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/cmd_common.py +27 -6
  9. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/check.py +23 -15
  10. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/dump_config.py +46 -18
  11. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/filetypes.py +75 -37
  12. topmark-0.5.0/src/topmark/cli/commands/init_config.py +83 -0
  13. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/processors.py +88 -37
  14. topmark-0.5.0/src/topmark/cli/commands/show_defaults.py +83 -0
  15. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/strip.py +26 -17
  16. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/version.py +21 -4
  17. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/config_resolver.py +7 -1
  18. topmark-0.5.0/src/topmark/cli/console.py +111 -0
  19. topmark-0.5.0/src/topmark/cli/console_helpers.py +37 -0
  20. topmark-0.5.0/src/topmark/cli/console_std.py +51 -0
  21. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/errors.py +27 -10
  22. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/main.py +22 -8
  23. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/options.py +4 -22
  24. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/utils.py +24 -31
  25. topmark-0.5.0/src/topmark/cli_shared/console_api.py +41 -0
  26. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli_shared/utils.py +1 -38
  27. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/config/__init__.py +27 -0
  28. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/config/logging.py +35 -6
  29. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/context.py +190 -5
  30. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/pipelines.py +1 -2
  31. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/processors/base.py +6 -8
  32. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/builder.py +2 -4
  33. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/reader.py +5 -3
  34. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/resolver.py +3 -5
  35. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/stripper.py +1 -6
  36. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/updater.py +11 -8
  37. {topmark-0.3.2 → topmark-0.5.0/src/topmark.egg-info}/PKG-INFO +19 -12
  38. {topmark-0.3.2 → topmark-0.5.0}/src/topmark.egg-info/SOURCES.txt +4 -0
  39. topmark-0.3.2/src/topmark/cli/commands/init_config.py +0 -63
  40. topmark-0.3.2/src/topmark/cli/commands/show_defaults.py +0 -58
  41. {topmark-0.3.2 → topmark-0.5.0}/LICENSE +0 -0
  42. {topmark-0.3.2 → topmark-0.5.0}/setup.cfg +0 -0
  43. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/__init__.py +0 -0
  44. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/__init__.py +0 -0
  45. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/commands/__init__.py +0 -0
  46. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli/io.py +0 -0
  47. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli_shared/__init__.py +0 -0
  48. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/cli_shared/exit_codes.py +0 -0
  49. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/config/topmark-default.toml +0 -0
  50. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/constants.py +0 -0
  51. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/file_resolver.py +0 -0
  52. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/filetypes/__init__.py +0 -0
  53. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/filetypes/base.py +0 -0
  54. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/filetypes/instances.py +0 -0
  55. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/filetypes/policy.py +0 -0
  56. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/filetypes/registry.py +0 -0
  57. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/__init__.py +0 -0
  58. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/contracts.py +0 -0
  59. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/processors/__init__.py +0 -0
  60. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/processors/pound.py +0 -0
  61. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/processors/slash.py +0 -0
  62. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/processors/xml.py +0 -0
  63. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/runner.py +0 -0
  64. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/__init__.py +0 -0
  65. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/comparer.py +0 -0
  66. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/patcher.py +0 -0
  67. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/renderer.py +0 -0
  68. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/pipeline/steps/scanner.py +0 -0
  69. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/py.typed +0 -0
  70. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/registry/__init__.py +0 -0
  71. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/registry/filetypes.py +0 -0
  72. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/registry/processors.py +0 -0
  73. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/registry/registry.py +0 -0
  74. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/rendering/api.py +0 -0
  75. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/rendering/formats.py +0 -0
  76. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/utils/__init__.py +0 -0
  77. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/utils/diff.py +0 -0
  78. {topmark-0.3.2 → topmark-0.5.0}/src/topmark/utils/file.py +0 -0
  79. {topmark-0.3.2 → topmark-0.5.0}/src/topmark.egg-info/dependency_links.txt +0 -0
  80. {topmark-0.3.2 → topmark-0.5.0}/src/topmark.egg-info/entry_points.txt +0 -0
  81. {topmark-0.3.2 → topmark-0.5.0}/src/topmark.egg-info/requires.txt +0 -0
  82. {topmark-0.3.2 → topmark-0.5.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.5.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
@@ -97,6 +97,7 @@ concise landing page and deep links into topics (install, usage, CI, API, etc.).
97
97
  - Full header removal (`topmark strip`)
98
98
  - Preserves original newline style (LF/CRLF/CR) and BOM
99
99
  - Idempotent updates (re-running does not change already-correct files)
100
+ - Structured diagnostics with severity levels (`info`, `warning`, `error`) and aggregate counts
100
101
 
101
102
  ## 🚀 Installation
102
103
 
@@ -120,19 +121,24 @@ pip install topmark
120
121
 
121
122
  ## ⚙️ Usage
122
123
 
124
+ > **Note**: In dry-run mode, TopMark shows summaries ending with `- previewed`. When run with `--apply`, summaries end with terminal statuses such as `- inserted`, `- replaced`, or `- removed`.
125
+
123
126
  ```bash
124
127
  topmark [SUBCOMMAND] [OPTIONS] [PATHS]...
125
128
  ```
126
129
 
127
- TopMark uses Click 8.2 and supports shell completions. The base command performs a dry‑run *check*
128
- by default and applies changes when `--apply` is provided.
129
-
130
- The `strip` subcommand is provided to remove entire headers.
130
+ The `check` and `strip` commands performs a dry‑run by default
131
+ and will only apply changes when `--apply` is provided.
131
132
 
132
133
  Logging verbosity is controlled globally:
133
134
 
134
- - `-v`, `--verbose`: Increase verbosity (can be repeated)
135
- - `-q`, `--quiet`: Suppress most output (overrides verbosity)
135
+ CLI verbosity has two layers:
136
+
137
+ - **Program output** (user-facing):
138
+ - `-v`, `--verbose`: Show more detail, including per-line diagnostics in summaries.
139
+ - `-q`, `--quiet`: Suppress most output (overrides `-v`).
140
+ - **Internal logging** (developer/machine):
141
+ - Also mapped by `-v`/`-q`, controls log level (`DEBUG` / `INFO` / `WARNING` / `ERROR`).
136
142
 
137
143
  All other options are specific to individual subcommands.
138
144
 
@@ -142,6 +148,7 @@ ______________________________________________________________________
142
148
 
143
149
  | Command | Description |
144
150
  | --------------- | -------------------------------------------------- |
151
+ | `check` | Add / Update TopMark headers in files |
145
152
  | `dump-config` | Show the resolved configuration in TOML format |
146
153
  | `filetypes` | List supported file types and their comment styles |
147
154
  | `processors` | List registered header processors and file types |
@@ -171,10 +178,10 @@ find . -name "*.py" | topmark check --file-type python --files-from - --summary
171
178
  git ls-files -c -o --exclude-standard | sort -u | topmark check --files-from - --apply
172
179
 
173
180
  # Read a single file's *content* from STDIN and render the resultng unified diff
174
- cat README.md | topmark - --stdin-filename README.md --diff
181
+ cat README.md | topmark check - --stdin-filename README.md --diff
175
182
 
176
183
  # Read a single file's *content* from STDIN and print the updated content to stdout
177
- cat README.md | topmark - --stdin-filename README.md --apply
184
+ cat README.md | topmark check - --stdin-filename README.md --apply
178
185
 
179
186
  # Dump the merged configuration (after loading all applicable config layers)
180
187
  topmark dump-config --file-type python --exclude .venv --exclude-from .gitignore
@@ -200,8 +207,8 @@ topmark strip --apply src/
200
207
  # CI-friendly summary: only show issues; ignore unsupported types
201
208
  topmark check --skip-compliant --skip-unsupported src/
202
209
 
203
- # Apply fixes, don't report unsupported files, muted output (useful for pre-commit)
204
- topmark check --apply --skip-unsupported --quiet
210
+ # Apply fixes, don't report unsupported files; default output is terse (pre-commit friendly)
211
+ topmark check --apply --skip-unsupported
205
212
 
206
213
  # Show supported file types in MarkDown format, providing detailed output:
207
214
  topmark filetypes --format markdown --long
@@ -274,7 +281,7 @@ declaration for XML/HTML, etc.). Newline style and BOM are preserved; runs are i
274
281
 
275
282
  ```bash
276
283
  # Preview (exit code 2 when changes are pending)
277
- topmark src/
284
+ topmark check src/
278
285
 
279
286
  # Apply in place
280
287
  topmark check --apply src/
@@ -47,6 +47,7 @@ concise landing page and deep links into topics (install, usage, CI, API, etc.).
47
47
  - Full header removal (`topmark strip`)
48
48
  - Preserves original newline style (LF/CRLF/CR) and BOM
49
49
  - Idempotent updates (re-running does not change already-correct files)
50
+ - Structured diagnostics with severity levels (`info`, `warning`, `error`) and aggregate counts
50
51
 
51
52
  ## 🚀 Installation
52
53
 
@@ -70,19 +71,24 @@ pip install topmark
70
71
 
71
72
  ## ⚙️ Usage
72
73
 
74
+ > **Note**: In dry-run mode, TopMark shows summaries ending with `- previewed`. When run with `--apply`, summaries end with terminal statuses such as `- inserted`, `- replaced`, or `- removed`.
75
+
73
76
  ```bash
74
77
  topmark [SUBCOMMAND] [OPTIONS] [PATHS]...
75
78
  ```
76
79
 
77
- TopMark uses Click 8.2 and supports shell completions. The base command performs a dry‑run *check*
78
- by default and applies changes when `--apply` is provided.
79
-
80
- The `strip` subcommand is provided to remove entire headers.
80
+ The `check` and `strip` commands performs a dry‑run by default
81
+ and will only apply changes when `--apply` is provided.
81
82
 
82
83
  Logging verbosity is controlled globally:
83
84
 
84
- - `-v`, `--verbose`: Increase verbosity (can be repeated)
85
- - `-q`, `--quiet`: Suppress most output (overrides verbosity)
85
+ CLI verbosity has two layers:
86
+
87
+ - **Program output** (user-facing):
88
+ - `-v`, `--verbose`: Show more detail, including per-line diagnostics in summaries.
89
+ - `-q`, `--quiet`: Suppress most output (overrides `-v`).
90
+ - **Internal logging** (developer/machine):
91
+ - Also mapped by `-v`/`-q`, controls log level (`DEBUG` / `INFO` / `WARNING` / `ERROR`).
86
92
 
87
93
  All other options are specific to individual subcommands.
88
94
 
@@ -92,6 +98,7 @@ ______________________________________________________________________
92
98
 
93
99
  | Command | Description |
94
100
  | --------------- | -------------------------------------------------- |
101
+ | `check` | Add / Update TopMark headers in files |
95
102
  | `dump-config` | Show the resolved configuration in TOML format |
96
103
  | `filetypes` | List supported file types and their comment styles |
97
104
  | `processors` | List registered header processors and file types |
@@ -121,10 +128,10 @@ find . -name "*.py" | topmark check --file-type python --files-from - --summary
121
128
  git ls-files -c -o --exclude-standard | sort -u | topmark check --files-from - --apply
122
129
 
123
130
  # Read a single file's *content* from STDIN and render the resultng unified diff
124
- cat README.md | topmark - --stdin-filename README.md --diff
131
+ cat README.md | topmark check - --stdin-filename README.md --diff
125
132
 
126
133
  # Read a single file's *content* from STDIN and print the updated content to stdout
127
- cat README.md | topmark - --stdin-filename README.md --apply
134
+ cat README.md | topmark check - --stdin-filename README.md --apply
128
135
 
129
136
  # Dump the merged configuration (after loading all applicable config layers)
130
137
  topmark dump-config --file-type python --exclude .venv --exclude-from .gitignore
@@ -150,8 +157,8 @@ topmark strip --apply src/
150
157
  # CI-friendly summary: only show issues; ignore unsupported types
151
158
  topmark check --skip-compliant --skip-unsupported src/
152
159
 
153
- # Apply fixes, don't report unsupported files, muted output (useful for pre-commit)
154
- topmark check --apply --skip-unsupported --quiet
160
+ # Apply fixes, don't report unsupported files; default output is terse (pre-commit friendly)
161
+ topmark check --apply --skip-unsupported
155
162
 
156
163
  # Show supported file types in MarkDown format, providing detailed output:
157
164
  topmark filetypes --format markdown --long
@@ -224,7 +231,7 @@ declaration for XML/HTML, etc.). Newline style and BOM are preserved; runs are i
224
231
 
225
232
  ```bash
226
233
  # Preview (exit code 2 when changes are pending)
227
- topmark src/
234
+ topmark check src/
228
235
 
229
236
  # Apply in place
230
237
  topmark check --apply src/
@@ -10,7 +10,7 @@
10
10
 
11
11
  [project]
12
12
  name = "topmark"
13
- version = "0.3.2"
13
+ version = "0.5.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,12 +34,11 @@ 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
40
41
 
41
- import logging
42
- import os
43
42
  from pathlib import Path
44
43
  from typing import Any, Iterable, Mapping, Sequence
45
44
 
@@ -50,15 +49,18 @@ from topmark.cli.cmd_common import (
50
49
  from topmark.cli.io import InputPlan
51
50
  from topmark.cli_shared.utils import write_updates
52
51
  from topmark.config import Config
53
- from topmark.config.logging import get_logger, setup_logging
52
+ from topmark.config.logging import get_logger
54
53
  from topmark.constants import TOPMARK_VERSION
55
- from topmark.pipeline.context import ComparisonStatus, FileStatus, ProcessingContext, WriteStatus
54
+ from topmark.pipeline.context import (
55
+ ComparisonStatus,
56
+ FileStatus,
57
+ ProcessingContext,
58
+ WriteStatus,
59
+ )
56
60
  from topmark.registry import Registry
57
61
 
58
- from .types import FileResult, FileTypeInfo, Outcome, ProcessorInfo, RunResult
59
-
60
- if "TOPMARK_DEBUG" in os.environ:
61
- setup_logging(level=logging.DEBUG)
62
+ from .public_types import PublicDiagnostic
63
+ from .types import DiagnosticTotals, FileResult, FileTypeInfo, Outcome, ProcessorInfo, RunResult
62
64
 
63
65
  logger = get_logger(__name__)
64
66
 
@@ -75,6 +77,7 @@ __all__ = [
75
77
  "get_filetype_info",
76
78
  "get_processor_info",
77
79
  "Registry",
80
+ "PublicDiagnostic",
78
81
  ]
79
82
 
80
83
 
@@ -153,6 +156,7 @@ def _run_pipeline(
153
156
  paths: Iterable[Path | str],
154
157
  base_config: Mapping[str, Any] | Config | None,
155
158
  file_types: Sequence[str] | None,
159
+ apply_changes: bool,
156
160
  ) -> tuple[Config, list[Path], list[ProcessingContext], object | None]:
157
161
  """Resolve config + files, register processors, and run the pipeline.
158
162
 
@@ -168,6 +172,10 @@ def _run_pipeline(
168
172
  cfg, file_list = _build_cfg_and_files_via_cli_helpers(
169
173
  paths, base_config=base_config, file_types=file_types
170
174
  )
175
+
176
+ # Set the 'apply' mode in the Config instance
177
+ cfg.apply_changes = apply_changes
178
+
171
179
  if not file_list:
172
180
  return cfg, file_list, [], None
173
181
 
@@ -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
 
@@ -269,7 +298,7 @@ def _build_cfg_and_files_via_cli_helpers(
269
298
  # but without relying on Click. We construct the ArgsNamespace-equivalent
270
299
  # mapping that `Config.load_merged()` expects.
271
300
  args_mapping = {
272
- "log_level": logging.WARNING,
301
+ "verbosity_level": None, # inherit program-output verbosity (tri-state)
273
302
  "files": list(plan.paths),
274
303
  "files_from": list(plan.files_from),
275
304
  "stdin": False,
@@ -340,6 +369,7 @@ def check(
340
369
  paths=paths,
341
370
  base_config=config,
342
371
  file_types=file_types,
372
+ apply_changes=apply,
343
373
  )
344
374
  if not file_list:
345
375
  return RunResult(files=(), summary={}, had_errors=False)
@@ -354,6 +384,8 @@ def check(
354
384
  encountered_error_code is not None
355
385
  )
356
386
  diagnostics = _collect_diagnostics(view_results)
387
+ diagnostic_totals = _collect_diagnostic_totals(view_results)
388
+ diagnostic_totals_all = _collect_diagnostic_totals(results)
357
389
 
358
390
  written = failed = 0
359
391
  if apply:
@@ -380,6 +412,8 @@ def check(
380
412
  written=written,
381
413
  failed=failed,
382
414
  diagnostics=diagnostics,
415
+ diagnostic_totals=diagnostic_totals,
416
+ diagnostic_totals_all=diagnostic_totals_all,
383
417
  )
384
418
 
385
419
 
@@ -418,6 +452,7 @@ def strip(
418
452
  paths=paths,
419
453
  base_config=config,
420
454
  file_types=file_types,
455
+ apply_changes=apply,
421
456
  )
422
457
  if not file_list:
423
458
  return RunResult(files=(), summary={}, had_errors=False)
@@ -432,6 +467,8 @@ def strip(
432
467
  encountered_error_code is not None
433
468
  )
434
469
  diagnostics = _collect_diagnostics(view_results)
470
+ diagnostic_totals = _collect_diagnostic_totals(view_results)
471
+ diagnostic_totals_all = _collect_diagnostic_totals(results)
435
472
 
436
473
  written = failed = 0
437
474
  if apply:
@@ -452,6 +489,8 @@ def strip(
452
489
  written=written,
453
490
  failed=failed,
454
491
  diagnostics=diagnostics,
492
+ diagnostic_totals=diagnostic_totals,
493
+ diagnostic_totals_all=diagnostic_totals_all,
455
494
  )
456
495
 
457
496
 
@@ -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
@@ -19,7 +19,6 @@ across all TopMark commands and subcommands.
19
19
  """
20
20
 
21
21
  import glob
22
- import logging
23
22
  from enum import Enum
24
23
  from pathlib import Path
25
24
  from typing import (
@@ -63,7 +62,8 @@ class ArgsNamespace(TypedDict, total=False):
63
62
  file overrides, file selection, filtering, and formatting flags.
64
63
 
65
64
  Attributes:
66
- log_level: Verbosity level for logging.
65
+ verbosity_level: Program-output verbosity (0=terse, 1=verbose).
66
+ apply_changes: Whether to apply the changed (dry-run if not set ot False).
67
67
  no_config: Whether to ignore local config files.
68
68
  config_files: List of extra config file paths.
69
69
  files: List of file paths to process.
@@ -79,10 +79,11 @@ class ArgsNamespace(TypedDict, total=False):
79
79
  """
80
80
 
81
81
  # Global options: retrieve from ctx.obj
82
- log_level: int
82
+ verbosity_level: int | None
83
83
 
84
- # TopMark Command
85
- # command: TopMarkCommand
84
+ # Runtime intent: whether to actually write changes (apply) or preview only
85
+ # None = inherit/unspecified, False = dry-run/preview, True = apply
86
+ apply_changes: bool | None
86
87
 
87
88
  # Command options: config
88
89
  no_config: bool | None
@@ -108,8 +109,8 @@ class ArgsNamespace(TypedDict, total=False):
108
109
 
109
110
  def build_args_namespace(
110
111
  *,
111
- log_level: int = logging.WARNING,
112
- # command: TopMarkCommand = TopMarkCommand.NONE,
112
+ verbosity_level: int | None = None,
113
+ apply_changes: bool | None = None,
113
114
  no_config: bool | None = None,
114
115
  config_files: list[str] | None = None,
115
116
  files: list[str] | None = None,
@@ -127,7 +128,8 @@ def build_args_namespace(
127
128
  """Build an ArgsNamespace dictionary for CLI argument passing.
128
129
 
129
130
  Args:
130
- log_level: Verbosity level for logging.
131
+ verbosity_level: Program-output verbosity (0=terse, 1=verbose).
132
+ apply_changes: Whether to apply the changed (dry-run if not set ot False).
131
133
  no_config: Whether to ignore local config files.
132
134
  config_files: List of extra config file paths.
133
135
  files: List of file paths to process.
@@ -146,8 +148,8 @@ def build_args_namespace(
146
148
  Dictionary of CLI argument values for use throughout the CLI.
147
149
  """
148
150
  return {
149
- "log_level": log_level,
150
- # "command": command,
151
+ "verbosity_level": verbosity_level,
152
+ "apply_changes": apply_changes,
151
153
  "no_config": no_config,
152
154
  "config_files": config_files,
153
155
  "files": files if files is not None else [],
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
23
23
  import click
24
24
 
25
25
  from topmark.cli.config_resolver import resolve_config_from_click
26
+ from topmark.cli.console_helpers import get_console_safely
26
27
  from topmark.cli_shared.exit_codes import ExitCode
27
28
  from topmark.config.logging import get_logger
28
29
  from topmark.file_resolver import resolve_file_list
@@ -38,6 +39,20 @@ if TYPE_CHECKING:
38
39
  logger = get_logger(__name__)
39
40
 
40
41
 
42
+ def get_effective_verbosity(ctx: click.Context, config: "Config | None" = None) -> int:
43
+ """Return the effective program-output verbosity for this command.
44
+
45
+ Resolution order (tri-state aware):
46
+ 1. Config.verbosity_level if set (not None)
47
+ 2. ctx.obj["verbosity_level"] if present
48
+ 3. 0 (terse)
49
+ """
50
+ cfg_level = getattr(config, "verbosity_level", None) if config else None
51
+ if cfg_level is not None:
52
+ return int(cfg_level)
53
+ return int(ctx.obj.get("verbosity_level", 0))
54
+
55
+
41
56
  def build_file_list(config: Config, *, stdin_mode: bool, temp_path: Path | None) -> list[Path]:
42
57
  """Return the files to process, respecting STDIN content mode.
43
58
 
@@ -67,6 +82,7 @@ def run_steps_for_files(
67
82
  ENCODING_ERROR → UnicodeDecodeError
68
83
  PIPELINE_ERROR → any other unexpected exception
69
84
  """
85
+ console = get_console_safely()
70
86
  steps = get_pipeline(pipeline_name)
71
87
  results: list[ProcessingContext] = []
72
88
  encountered_error_code: ExitCode | None = None
@@ -78,7 +94,7 @@ def run_steps_for_files(
78
94
  results.append(ctx_obj)
79
95
  except (FileNotFoundError, PermissionError, IsADirectoryError) as e:
80
96
  logger.error("Filesystem error while processing %s: %s", path, e)
81
- click.secho(f"❌ Filesystem error processing {path}: {e}", fg="red")
97
+ console.error(f"❌ Filesystem error processing {path}: {e}")
82
98
  if isinstance(e, (FileNotFoundError, IsADirectoryError)):
83
99
  logger.error("%s: %s", e, path)
84
100
  encountered_error_code = encountered_error_code or ExitCode.FILE_NOT_FOUND
@@ -88,14 +104,12 @@ def run_steps_for_files(
88
104
  continue
89
105
  except UnicodeDecodeError as e:
90
106
  logger.error("Encoding error while reading %s: %s", path, e)
91
- click.secho(f"🧵 Encoding error in {path}: {e}", fg="red")
107
+ console.error(f"🧵 Encoding error in {path}: {e}")
92
108
  encountered_error_code = encountered_error_code or ExitCode.ENCODING_ERROR
93
109
  continue
94
110
  except Exception as e: # pragma: no cover
95
111
  logger.exception("Unexpected error processing %s", path)
96
- click.secho(
97
- f"⚠️ Unexpected error processing {path}: {e} (use -vv for traceback)", fg="red"
98
- )
112
+ console.error(f"⚠️ Unexpected error processing {path}: {e} (use -vv for traceback)")
99
113
  encountered_error_code = encountered_error_code or ExitCode.PIPELINE_ERROR
100
114
  continue
101
115
 
@@ -134,7 +148,8 @@ def filter_view_results(
134
148
  def exit_if_no_files(file_list: list[Path]) -> bool:
135
149
  """Echo a friendly message and return True if there is nothing to process."""
136
150
  if not file_list:
137
- click.secho("\nℹ️ No files to process.\n", fg="blue")
151
+ console = get_console_safely()
152
+ console.print(console.styled("\nℹ️ No files to process.\n", fg="blue"))
138
153
  return True
139
154
  return False
140
155
 
@@ -162,6 +177,8 @@ def build_config_common(
162
177
  """Materialize Config from an input plan (no file list resolution)."""
163
178
  return resolve_config_from_click(
164
179
  ctx=ctx,
180
+ verbosity_level=ctx.obj.get("verbosity_level"),
181
+ apply_changes=ctx.obj.get("apply_changes"),
165
182
  files=plan.paths,
166
183
  files_from=plan.files_from,
167
184
  stdin=plan.stdin_mode,
@@ -193,6 +210,8 @@ def build_config_and_file_list(
193
210
  if plan.stdin_mode:
194
211
  config = resolve_config_from_click(
195
212
  ctx=ctx,
213
+ verbosity_level=ctx.obj.get("verbosity_level"),
214
+ apply_changes=ctx.obj.get("apply_changes"),
196
215
  files=plan.paths,
197
216
  files_from=plan.files_from,
198
217
  stdin=True,
@@ -211,6 +230,8 @@ def build_config_and_file_list(
211
230
  else:
212
231
  config = resolve_config_from_click(
213
232
  ctx=ctx,
233
+ verbosity_level=ctx.obj.get("verbosity_level"),
234
+ apply_changes=ctx.obj.get("apply_changes"),
214
235
  files=plan.paths,
215
236
  files_from=plan.files_from,
216
237
  stdin=False,