Markdown-Checker 1.0.3__tar.gz → 1.1.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 (81) hide show
  1. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/PKG-INFO +2 -3
  2. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/advanced.rst +51 -0
  3. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/main.rst +1 -0
  4. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/models.rst +8 -0
  5. markdown_checker-1.1.0/docs/api/patterns.rst +7 -0
  6. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/utils.rst +16 -0
  7. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/conf.py +4 -0
  8. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/index.rst +1 -1
  9. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/pyproject.toml +4 -5
  10. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/__init__.py +2 -2
  11. markdown_checker-1.1.0/src/markdown_checker/checker.py +144 -0
  12. markdown_checker-1.1.0/src/markdown_checker/checks/base.py +82 -0
  13. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/broken_paths.py +2 -0
  14. markdown_checker-1.1.0/src/markdown_checker/checks/broken_urls.py +88 -0
  15. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/locale.py +2 -0
  16. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/tracking.py +3 -0
  17. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/cli.py +30 -1
  18. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/base.py +4 -5
  19. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/config.py +3 -0
  20. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/path.py +3 -5
  21. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/url.py +30 -36
  22. markdown_checker-1.1.0/src/markdown_checker/patterns.py +27 -0
  23. markdown_checker-1.1.0/src/markdown_checker/reports/format_output.py +86 -0
  24. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/__init__.py +2 -0
  25. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/extract_links.py +26 -27
  26. markdown_checker-1.1.0/src/markdown_checker/utils/github_env.py +57 -0
  27. markdown_checker-1.1.0/src/markdown_checker/utils/url_pipeline.py +254 -0
  28. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/smoke/test_cli_smoke.py +1 -1
  29. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checker.py +115 -0
  30. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_broken_urls.py +46 -79
  31. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_cli.py +1 -1
  32. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_models_base.py +3 -0
  33. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_models_url.py +41 -64
  34. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_utils_extract_links.py +54 -0
  35. markdown_checker-1.1.0/tests/test_utils_format_output.py +139 -0
  36. markdown_checker-1.1.0/tests/test_utils_github_env.py +105 -0
  37. markdown_checker-1.1.0/tests/test_utils_url_pipeline.py +347 -0
  38. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/uv.lock +360 -557
  39. markdown_checker-1.0.3/src/markdown_checker/checker.py +0 -84
  40. markdown_checker-1.0.3/src/markdown_checker/checks/base.py +0 -36
  41. markdown_checker-1.0.3/src/markdown_checker/checks/broken_urls.py +0 -86
  42. markdown_checker-1.0.3/src/markdown_checker/reports/format_output.py +0 -58
  43. markdown_checker-1.0.3/tests/test_utils_format_output.py +0 -86
  44. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/LICENSE +0 -0
  45. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/README.md +0 -0
  46. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/CODE_OF_CONDUCT.rst +0 -0
  47. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/CONTRIBUTING.rst +0 -0
  48. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/checker.rst +0 -0
  49. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/checks.rst +0 -0
  50. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/cli.rst +0 -0
  51. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/reports.rst +0 -0
  52. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/automate.rst +0 -0
  53. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/changes.rst +0 -0
  54. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/license.rst +0 -0
  55. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/usage.rst +0 -0
  56. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/__init__.py +0 -0
  57. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/__init__.py +0 -0
  58. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/py.typed +0 -0
  59. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/__init__.py +0 -0
  60. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/base.py +0 -0
  61. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/markdown.py +0 -0
  62. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/paths/broken.md +0 -0
  63. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/paths/tracking.md +0 -0
  64. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/urls/broken.md +0 -0
  65. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/urls/locale.md +0 -0
  66. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/urls/tracking.md +0 -0
  67. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/list_files.py +0 -0
  68. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/spinner.py +0 -0
  69. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/conftest.py +0 -0
  70. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample1.md +0 -0
  71. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample2.md +0 -0
  72. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample3.md +0 -0
  73. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample4.md +0 -0
  74. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_broken_paths.py +0 -0
  75. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_locale.py +0 -0
  76. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_registry.py +0 -0
  77. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_tracking.py +0 -0
  78. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_models_path.py +0 -0
  79. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_reports.py +0 -0
  80. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_utils_list_files.py +0 -0
  81. {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_utils_spinner.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-checker
3
- Version: 1.0.3
3
+ Version: 1.1.0
4
4
  Summary: A markdown link validation reporting tool.
5
5
  Author-email: John Aziz <johnaziz269@gmail.com>
6
6
  Maintainer-email: John Aziz <johnaziz269@gmail.com>
7
- Requires-Python: >=3.10
7
+ Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  License-Expression: MIT
10
10
  Classifier: Development Status :: 5 - Production/Stable
@@ -12,7 +12,6 @@ Classifier: Environment :: Console
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python
14
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
@@ -285,6 +285,57 @@ Usage examples
285
285
  # Short-form alias
286
286
  markdown-checker -d . -f check_broken_urls -frd 45
287
287
 
288
+ ``-mw``, ``--max-workers``
289
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
290
+
291
+ Type
292
+ ``Click.IntRange``
293
+ Description
294
+ Maximum number of concurrent URL-check workers.
295
+ Default
296
+ ``10``, or the number of available CPUs when running in GitHub Actions
297
+ (``$GITHUB_ACTIONS=true``).
298
+ Range
299
+ ``1`` or greater.
300
+ Required
301
+ No.
302
+
303
+ Usage examples
304
+ ^^^^^^^^^^^^^^
305
+
306
+ .. code-block:: bash
307
+
308
+ # Use a custom worker count
309
+ markdown-checker -d . -f check_broken_urls --max-workers=20
310
+
311
+ # Short-form alias
312
+ markdown-checker -d . -f check_broken_urls -mw 20
313
+
314
+ ``-phd``, ``--per-host-delay``
315
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
316
+
317
+ Type
318
+ ``Click.FloatRange``
319
+ Description
320
+ Minimum delay in seconds between requests to the same host.
321
+ Default
322
+ ``0.5``
323
+ Range
324
+ ``0.0-10.0``
325
+ Required
326
+ No.
327
+
328
+ Usage examples
329
+ ^^^^^^^^^^^^^^
330
+
331
+ .. code-block:: bash
332
+
333
+ # Use a custom per-host delay of 1 second
334
+ markdown-checker -d . -f check_broken_urls --per-host-delay=1.0
335
+
336
+ # Short-form alias
337
+ markdown-checker -d . -f check_broken_urls -phd 1.0
338
+
288
339
  ``-o``, ``--output-file-name``
289
340
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
290
341
 
@@ -4,3 +4,4 @@ Main
4
4
  .. automodule:: markdown_checker
5
5
  :members:
6
6
  :undoc-members:
7
+ :noindex:
@@ -9,6 +9,14 @@ Base
9
9
  :undoc-members:
10
10
  :show-inheritance:
11
11
 
12
+ Config
13
+ ------
14
+
15
+ .. automodule:: markdown_checker.models.config
16
+ :members:
17
+ :undoc-members:
18
+ :show-inheritance:
19
+
12
20
  Path
13
21
  ----
14
22
 
@@ -0,0 +1,7 @@
1
+ Patterns
2
+ ========
3
+
4
+ .. automodule:: markdown_checker.patterns
5
+ :members:
6
+ :undoc-members:
7
+ :show-inheritance:
@@ -9,6 +9,14 @@ Extract Links
9
9
  :undoc-members:
10
10
  :show-inheritance:
11
11
 
12
+ GitHub Environment
13
+ -------------------
14
+
15
+ .. automodule:: markdown_checker.utils.github_env
16
+ :members:
17
+ :undoc-members:
18
+ :show-inheritance:
19
+
12
20
  List Files
13
21
  ----------
14
22
 
@@ -24,3 +32,11 @@ Spinner
24
32
  :members:
25
33
  :undoc-members:
26
34
  :show-inheritance:
35
+
36
+ URL Pipeline
37
+ ------------
38
+
39
+ .. automodule:: markdown_checker.utils.url_pipeline
40
+ :members:
41
+ :undoc-members:
42
+ :show-inheritance:
@@ -15,6 +15,7 @@ extensions = [
15
15
  "sphinx.ext.autodoc",
16
16
  "sphinx.ext.extlinks",
17
17
  "sphinx.ext.intersphinx",
18
+ "sphinx.ext.napoleon",
18
19
  "sphinxcontrib.log_cabinet",
19
20
  "sphinx_tabs.tabs",
20
21
  "sphinx_copybutton",
@@ -23,6 +24,9 @@ extensions = [
23
24
  autodoc_member_order = "bysource"
24
25
  autodoc_typehints = "description"
25
26
  autodoc_preserve_defaults = True
27
+ napoleon_google_docstring = True
28
+ napoleon_numpy_docstring = False
29
+ napoleon_use_rtype = False
26
30
  extlinks = {
27
31
  "issue": ("https://github.com/john0isaac/markdown-checker/issues/%s", "#%s"),
28
32
  "pr": ("https://github.com/john0isaac/markdown-checker/pull/%s", "#%s"),
@@ -24,7 +24,7 @@ Requirements
24
24
 
25
25
  You will need the following prerequisites in order to use this library:
26
26
 
27
- - Python >= 3.10
27
+ - Python >= 3.11
28
28
  - `httpx2 <https://pypi.org/project/httpx2/>`_
29
29
  - `click <https://pypi.org/project/click/>`_
30
30
 
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "markdown-checker"
3
3
  description= "A markdown link validation reporting tool."
4
- version = "1.0.3"
4
+ version = "1.1.0"
5
5
  authors = [{ name = "John Aziz", email = "johnaziz269@gmail.com" }]
6
6
  maintainers = [{ name = "John Aziz", email = "johnaziz269@gmail.com" }]
7
7
  license = "MIT"
8
8
  license-files = ["LICENSE"]
9
9
  readme = "README.md"
10
10
 
11
- requires-python = ">=3.10"
11
+ requires-python = ">=3.11"
12
12
  dependencies = [
13
13
  "click>=8.3.1",
14
14
  "httpx2>=2.0.0",
@@ -19,7 +19,6 @@ classifiers = [
19
19
  "Operating System :: OS Independent",
20
20
  "Programming Language :: Python",
21
21
  "Programming Language :: Python :: 3 :: Only",
22
- "Programming Language :: Python :: 3.10",
23
22
  "Programming Language :: Python :: 3.11",
24
23
  "Programming Language :: Python :: 3.12",
25
24
  "Programming Language :: Python :: 3.13",
@@ -111,7 +110,7 @@ order-by-type = false
111
110
  known-first-party = ["markdown_checker"]
112
111
 
113
112
  [tool.mypy]
114
- python_version = '3.10'
113
+ python_version = '3.11'
115
114
  files = ["src"]
116
115
  show_error_codes = true
117
116
  pretty = true
@@ -147,7 +146,7 @@ exclude_also = [
147
146
 
148
147
  [tool.tox]
149
148
  env_list = [
150
- "py3.14", "py3.13", "py3.12", "py3.11", "py3.10",
149
+ "py3.14", "py3.13", "py3.12", "py3.11",
151
150
  "style",
152
151
  "typing",
153
152
  "docs",
@@ -1,8 +1,8 @@
1
1
  """
2
- markdown-checker — a markdown link validation reporting tool.
2
+ markdown-checker - a markdown link validation reporting tool.
3
3
  """
4
4
 
5
- __version__ = "1.0.3"
5
+ __version__ = "1.1.0"
6
6
 
7
7
  from markdown_checker.cli import main
8
8
  from markdown_checker.models import Config
@@ -0,0 +1,144 @@
1
+ from collections import deque
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from pathlib import Path
6
+
7
+ from markdown_checker.checks import REGISTRY
8
+ from markdown_checker.models import Config
9
+ from markdown_checker.models import MarkdownLinkBase
10
+ from markdown_checker.utils import get_links_from_md_file
11
+ from markdown_checker.utils.url_pipeline import URLCheckService
12
+
13
+ # Bounds on how many files may have their check submitted (but not yet collected) at
14
+ # once. The window drains early once enough links are in flight to keep every worker
15
+ # fed (target ~2x max_workers), so link-dense files don't balloon memory; it never
16
+ # drains before _MIN_FILE_WINDOW files, so link-sparse files (few links per file) still
17
+ # get real cross-file overlap; _MAX_FILE_WINDOW is an absolute cap regardless of
18
+ # link density.
19
+ _MIN_FILE_WINDOW = 4
20
+ _MAX_FILE_WINDOW = 64
21
+
22
+
23
+ @dataclass
24
+ class CheckResult:
25
+ """Result of running a check on one or more files."""
26
+
27
+ issues: list[tuple[Path, list[MarkdownLinkBase]]] = field(default_factory=list)
28
+ links_checked: int = 0
29
+
30
+
31
+ def detect_issues(
32
+ func: str,
33
+ file_path: Path,
34
+ config: Config,
35
+ service: URLCheckService | None = None,
36
+ ) -> tuple[list[MarkdownLinkBase], int]:
37
+ """
38
+ Detect issues in a single markdown file using the named check.
39
+
40
+ Args:
41
+ func (str): Name of the check to run (must be a key in REGISTRY).
42
+ file_path (Path): Path to the markdown file to check.
43
+ config (Config): Runtime configuration for the check.
44
+ service: Optional shared URL-checking pipeline, reused across files
45
+ in a run so duplicate URLs are only checked once.
46
+
47
+ Returns:
48
+ A tuple of (detected_issues, links_checked_count).
49
+
50
+ Raises:
51
+ ValueError: If func is not a registered check name.
52
+ """
53
+ check = REGISTRY.get(func)
54
+ if check is None:
55
+ raise ValueError(f"Unknown check function: {func!r}. Available: {list(REGISTRY)}")
56
+
57
+ all_links = get_links_from_md_file(file_path)
58
+ if not all_links.paths and not all_links.urls:
59
+ return [], 0
60
+
61
+ detected_issues = check.run(links=all_links, config=config, service=service)
62
+
63
+ links_count = len(all_links.paths) if check.link_type == "paths" else len(all_links.urls)
64
+ return detected_issues, links_count
65
+
66
+
67
+ def run_check_on_files(
68
+ func: str,
69
+ files_paths: list[Path],
70
+ config: Config,
71
+ progress_callback: Callable[[], None],
72
+ ) -> CheckResult:
73
+ """
74
+ Run a named check across multiple files.
75
+
76
+ Files are processed through an adaptive window: a file's check is submitted
77
+ (but not necessarily collected) as soon as it's reached, so link extraction
78
+ for upcoming files overlaps with the network wait of files already submitted.
79
+ The window drains early once roughly ``2 * config.max_workers`` links are in
80
+ flight - enough to keep every worker fed without link-dense files ballooning
81
+ memory - but never before ``_MIN_FILE_WINDOW`` files, so link-sparse files
82
+ still get real overlap; ``_MAX_FILE_WINDOW`` is an absolute cap either way.
83
+ ``progress_callback`` fires as each file is submitted rather than once its
84
+ results are collected, so the bar advances smoothly instead of lagging
85
+ behind in bursts.
86
+
87
+ Args:
88
+ func: Name of the check to run (must be a key in REGISTRY).
89
+ files_paths: List of markdown file paths to check.
90
+ config: Runtime configuration for the check.
91
+ progress_callback: Callback invoked once each file has been submitted.
92
+
93
+ Returns:
94
+ A CheckResult with per-file issues and total links checked.
95
+ """
96
+ check = REGISTRY.get(func)
97
+ if check is None:
98
+ raise ValueError(f"Unknown check function: {func!r}. Available: {list(REGISTRY)}")
99
+
100
+ result = CheckResult()
101
+ # Shared across all files so duplicate URLs are checked once, requests to the
102
+ # same host are paced, and a rate limit pauses that host instead of retrying
103
+ # it file-by-file.
104
+ service = URLCheckService(config) if check.link_type == "urls" else None
105
+ window: deque[tuple[Path, object, int]] = deque()
106
+ window_links = 0
107
+ target_links_in_flight = max(config.max_workers * 2, 1)
108
+
109
+ def drain_one() -> int:
110
+ """Collect the oldest submitted file and return its link count."""
111
+ file_path, pending, links_count = window.popleft()
112
+ detected_issues = check.collect(pending)
113
+ result.links_checked += links_count
114
+ if detected_issues:
115
+ result.issues.append((file_path, detected_issues))
116
+ return links_count
117
+
118
+ def window_full() -> bool:
119
+ if len(window) >= _MAX_FILE_WINDOW:
120
+ return True
121
+ return len(window) >= _MIN_FILE_WINDOW and window_links >= target_links_in_flight
122
+
123
+ try:
124
+ for file_path in files_paths:
125
+ all_links = get_links_from_md_file(file_path)
126
+ if not all_links.paths and not all_links.urls:
127
+ progress_callback()
128
+ continue
129
+
130
+ pending = check.submit(links=all_links, config=config, service=service)
131
+ links_count = len(all_links.paths) if check.link_type == "paths" else len(all_links.urls)
132
+ window.append((file_path, pending, links_count))
133
+ window_links += links_count
134
+ progress_callback()
135
+ if window_full():
136
+ window_links -= drain_one()
137
+
138
+ while window:
139
+ drain_one()
140
+ finally:
141
+ if service is not None:
142
+ service.close()
143
+
144
+ return result
@@ -0,0 +1,82 @@
1
+ from abc import ABC
2
+ from abc import abstractmethod
3
+ from typing import cast
4
+ from typing import Generic
5
+ from typing import Literal
6
+ from typing import TypeVar
7
+
8
+ from markdown_checker.models import Config
9
+ from markdown_checker.models import MarkdownLinkBase
10
+ from markdown_checker.utils.extract_links import MarkdownLinks
11
+ from markdown_checker.utils.url_pipeline import URLCheckService
12
+
13
+ TLink = TypeVar("TLink", bound=MarkdownLinkBase, covariant=True)
14
+
15
+
16
+ class BaseCheck(Generic[TLink], ABC):
17
+ """Abstract base class for all link checks."""
18
+
19
+ name: str
20
+ link_type: Literal["paths", "urls"]
21
+
22
+ @abstractmethod
23
+ def run(
24
+ self,
25
+ links: MarkdownLinks,
26
+ config: Config | None = None,
27
+ service: URLCheckService | None = None,
28
+ ) -> list[TLink]:
29
+ """
30
+ Run the check against the extracted links.
31
+
32
+ Args:
33
+ links (MarkdownLinks): Extracted URLs and paths from a file.
34
+ config: Runtime configuration for the check run.
35
+ service: Optional shared URL-checking pipeline (dedupe memo,
36
+ per-host pacing, circuit breaker). Only used by checks with
37
+ link_type "urls"; ignored otherwise. When None, URL checks
38
+ fall back to a private, run-scoped service instance.
39
+
40
+ Returns:
41
+ List of links that failed the check, each with an .issue set.
42
+ """
43
+ raise NotImplementedError
44
+
45
+ def submit(
46
+ self,
47
+ links: MarkdownLinks,
48
+ config: Config | None = None,
49
+ service: URLCheckService | None = None,
50
+ ) -> object:
51
+ """
52
+ Begin checking without blocking the caller, returning an opaque
53
+ "pending" token to be finished later via ``collect()``.
54
+
55
+ The default implementation simply runs the check synchronously and
56
+ hands the finished result to ``collect()`` unchanged - appropriate
57
+ for checks with no network I/O to overlap with other files. Checks
58
+ that can hand work off to a shared service (e.g. URL checks) should
59
+ override both ``submit()`` and ``collect()`` to split the non-blocking
60
+ submission from the blocking wait for results.
61
+
62
+ Args:
63
+ links (MarkdownLinks): Extracted URLs and paths from a file.
64
+ config: Runtime configuration for the check run.
65
+ service: Optional shared URL-checking pipeline.
66
+
67
+ Returns:
68
+ An opaque token to pass to ``collect()``.
69
+ """
70
+ return self.run(links=links, config=config, service=service)
71
+
72
+ def collect(self, pending: object) -> list[TLink]:
73
+ """
74
+ Resolve a pending token from ``submit()`` into final results.
75
+
76
+ Args:
77
+ pending: The token returned by a matching ``submit()`` call.
78
+
79
+ Returns:
80
+ List of links that failed the check, each with an .issue set.
81
+ """
82
+ return cast(list[TLink], pending)
@@ -2,6 +2,7 @@ from markdown_checker.checks.base import BaseCheck
2
2
  from markdown_checker.models import Config
3
3
  from markdown_checker.models import MarkdownPath
4
4
  from markdown_checker.utils.extract_links import MarkdownLinks
5
+ from markdown_checker.utils.url_pipeline import URLCheckService
5
6
 
6
7
 
7
8
  class BrokenPathsCheck(BaseCheck[MarkdownPath]):
@@ -14,6 +15,7 @@ class BrokenPathsCheck(BaseCheck[MarkdownPath]):
14
15
  self,
15
16
  links: MarkdownLinks,
16
17
  config: Config | None = None,
18
+ service: URLCheckService | None = None,
17
19
  ) -> list[MarkdownPath]:
18
20
  detected_issues: list[MarkdownPath] = []
19
21
  for path in links.paths:
@@ -0,0 +1,88 @@
1
+ from concurrent.futures import Future
2
+ from typing import cast
3
+
4
+ from markdown_checker.checks.base import BaseCheck
5
+ from markdown_checker.models import Config
6
+ from markdown_checker.models import MarkdownURL
7
+ from markdown_checker.models.url import URLCheckResult
8
+ from markdown_checker.utils.extract_links import MarkdownLinks
9
+ from markdown_checker.utils.url_pipeline import URLCheckService
10
+
11
+ # Domains known to block automated requests (e.g. Cloudflare 403);
12
+ # always skipped for URL checks.
13
+ _BUILTIN_SKIP_DOMAINS: list[str] = []
14
+
15
+ # Opaque token handed from submit() to collect(): the submitted jobs, plus the
16
+ # service to close afterwards if this check created a private one for itself.
17
+ _Pending = tuple[list[tuple[MarkdownURL, "Future[URLCheckResult]"]], URLCheckService | None]
18
+
19
+
20
+ def _to_issue(url: MarkdownURL, result: URLCheckResult) -> MarkdownURL | None:
21
+ """Map a URLCheckResult onto a MarkdownURL's issue fields."""
22
+ if result.status == "alive":
23
+ return None
24
+ if result.status == "broken":
25
+ url.issue = "is broken"
26
+ url.issue_level = "error"
27
+ return url
28
+ if result.status == "rate_limited":
29
+ url.issue = "was skipped due to rate limiting"
30
+ elif result.status == "unverifiable":
31
+ url.issue = "could not be verified (access was forbidden by the server)"
32
+ else:
33
+ # transient_error
34
+ url.issue = "could not be reached due to a network error"
35
+ url.issue_level = "warning"
36
+ return url
37
+
38
+
39
+ class BrokenURLsCheck(BaseCheck[MarkdownURL]):
40
+ """Check for URLs in markdown files that return non-2xx responses."""
41
+
42
+ name = "check_broken_urls"
43
+ link_type = "urls"
44
+
45
+ def submit(
46
+ self,
47
+ links: MarkdownLinks,
48
+ config: Config | None = None,
49
+ service: URLCheckService | None = None,
50
+ ) -> object:
51
+ config = config or Config()
52
+ effective_skip = [domain.lower() for domain in [*config.skip_domains, *_BUILTIN_SKIP_DOMAINS]]
53
+ skip_urls_containing = config.skip_urls_containing
54
+
55
+ # Standalone fallback (direct calls, tests): own a private service for this run only.
56
+ owns_service = service is None
57
+ _service = service or URLCheckService(config)
58
+
59
+ pending: list[tuple[MarkdownURL, Future[URLCheckResult]]] = []
60
+ for url in links.urls:
61
+ hostname = url.host_name().lower()
62
+ if any(domain in hostname for domain in effective_skip) or any(
63
+ substring in url.link for substring in skip_urls_containing
64
+ ):
65
+ continue
66
+ pending.append((url, _service.submit(url)))
67
+ return (pending, _service if owns_service else None)
68
+
69
+ def collect(self, pending: object) -> list[MarkdownURL]:
70
+ jobs, owned_service = cast(_Pending, pending)
71
+ try:
72
+ results: list[MarkdownURL] = []
73
+ for url, future in jobs:
74
+ issue = _to_issue(url, future.result())
75
+ if issue is not None:
76
+ results.append(issue)
77
+ return results
78
+ finally:
79
+ if owned_service is not None:
80
+ owned_service.close()
81
+
82
+ def run(
83
+ self,
84
+ links: MarkdownLinks,
85
+ config: Config | None = None,
86
+ service: URLCheckService | None = None,
87
+ ) -> list[MarkdownURL]:
88
+ return self.collect(self.submit(links, config=config, service=service))
@@ -2,6 +2,7 @@ from markdown_checker.checks.base import BaseCheck
2
2
  from markdown_checker.models import Config
3
3
  from markdown_checker.models import MarkdownURL
4
4
  from markdown_checker.utils.extract_links import MarkdownLinks
5
+ from markdown_checker.utils.url_pipeline import URLCheckService
5
6
 
6
7
  # Domains where locale is required in the URL path; always skipped for locale checks.
7
8
  _BUILTIN_SKIP_DOMAINS: list[str] = [
@@ -19,6 +20,7 @@ class URLsLocaleCheck(BaseCheck[MarkdownURL]):
19
20
  self,
20
21
  links: MarkdownLinks,
21
22
  config: Config | None = None,
23
+ service: URLCheckService | None = None,
22
24
  ) -> list[MarkdownURL]:
23
25
  config = config or Config()
24
26
  effective_skip = [*config.skip_domains, *_BUILTIN_SKIP_DOMAINS]
@@ -3,6 +3,7 @@ from markdown_checker.models import Config
3
3
  from markdown_checker.models import MarkdownPath
4
4
  from markdown_checker.models import MarkdownURL
5
5
  from markdown_checker.utils.extract_links import MarkdownLinks
6
+ from markdown_checker.utils.url_pipeline import URLCheckService
6
7
 
7
8
 
8
9
  class URLsTrackingCheck(BaseCheck[MarkdownURL]):
@@ -15,6 +16,7 @@ class URLsTrackingCheck(BaseCheck[MarkdownURL]):
15
16
  self,
16
17
  links: MarkdownLinks,
17
18
  config: Config | None = None,
19
+ service: URLCheckService | None = None,
18
20
  ) -> list[MarkdownURL]:
19
21
  config = config or Config()
20
22
  skip_domains = config.skip_domains
@@ -44,6 +46,7 @@ class PathsTrackingCheck(BaseCheck[MarkdownPath]):
44
46
  self,
45
47
  links: MarkdownLinks,
46
48
  config: Config | None = None,
49
+ service: URLCheckService | None = None,
47
50
  ) -> list[MarkdownPath]:
48
51
  detected_issues: list[MarkdownPath] = []
49
52
  for path in links.paths:
@@ -8,6 +8,7 @@ from markdown_checker.checker import run_check_on_files
8
8
  from markdown_checker.models.config import Config
9
9
  from markdown_checker.reports.format_output import format_issues_table
10
10
  from markdown_checker.reports.markdown import MarkdownGenerator
11
+ from markdown_checker.utils.github_env import get_github_repo_blob_url
11
12
  from markdown_checker.utils.list_files import get_files_paths_list
12
13
 
13
14
 
@@ -144,6 +145,23 @@ class ListOfStrings(click.Option):
144
145
  help="Fallback delay in seconds when a 429 response has no Retry-After header.",
145
146
  required=False,
146
147
  )
148
+ @click.option(
149
+ "-mw",
150
+ "--max-workers",
151
+ type=click.IntRange(min=1),
152
+ default=None,
153
+ help="Maximum number of concurrent URL-check workers. Defaults to 10, "
154
+ "or the number of available CPUs when running in GitHub Actions.",
155
+ required=False,
156
+ )
157
+ @click.option(
158
+ "-phd",
159
+ "--per-host-delay",
160
+ type=click.FloatRange(0.0, 10.0),
161
+ default=0.5,
162
+ help="Minimum delay in seconds between requests to the same host.",
163
+ required=False,
164
+ )
147
165
  @click.option(
148
166
  "-o",
149
167
  "--output-file-name",
@@ -171,9 +189,17 @@ def main(
171
189
  retries: int,
172
190
  retry_on_429: bool,
173
191
  fallback_retry_delay: int,
192
+ max_workers: int | None,
193
+ per_host_delay: float,
174
194
  output_file_name: str,
175
195
  ) -> None:
176
196
  """A markdown link validation reporting tool."""
197
+ if max_workers is None:
198
+ if os.getenv("GITHUB_ACTIONS") == "true":
199
+ max_workers = max(1, os.cpu_count() or 10)
200
+ else:
201
+ max_workers = 10
202
+
177
203
  if src:
178
204
  files_paths = []
179
205
  for p in src:
@@ -198,8 +224,11 @@ def main(
198
224
  retries=retries,
199
225
  retry_on_429=retry_on_429,
200
226
  fallback_retry_delay=fallback_retry_delay,
227
+ max_workers=max_workers,
228
+ per_host_delay=per_host_delay,
201
229
  output_mode="ci" if os.getenv("CI", "false") == "true" else "local",
202
230
  )
231
+ repo_url = get_github_repo_blob_url() if config.output_mode == "ci" else None
203
232
 
204
233
  with click.progressbar(length=len(files_paths), label="Checking", hidden=config.output_mode == "ci") as bar:
205
234
  check_result = run_check_on_files(
@@ -251,7 +280,7 @@ def main(
251
280
  error_by_file.append((path, file_errors))
252
281
 
253
282
  if error_count:
254
- formatted_output = format_issues_table(error_by_file, config.output_mode)
283
+ formatted_output = format_issues_table(error_by_file, config.output_mode, repo_url)
255
284
  generator = MarkdownGenerator(contributing_guide_url=guide_url, output_file_name=output_file_name)
256
285
  generator.generate(func, formatted_output)
257
286
  click.echo(click.style(f"😭 Found {error_count} issues in the following files:", fg="red"), err=True)