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.
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/PKG-INFO +2 -3
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/advanced.rst +51 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/main.rst +1 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/models.rst +8 -0
- markdown_checker-1.1.0/docs/api/patterns.rst +7 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/utils.rst +16 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/conf.py +4 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/index.rst +1 -1
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/pyproject.toml +4 -5
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/__init__.py +2 -2
- markdown_checker-1.1.0/src/markdown_checker/checker.py +144 -0
- markdown_checker-1.1.0/src/markdown_checker/checks/base.py +82 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/broken_paths.py +2 -0
- markdown_checker-1.1.0/src/markdown_checker/checks/broken_urls.py +88 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/locale.py +2 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/tracking.py +3 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/cli.py +30 -1
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/base.py +4 -5
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/config.py +3 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/path.py +3 -5
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/url.py +30 -36
- markdown_checker-1.1.0/src/markdown_checker/patterns.py +27 -0
- markdown_checker-1.1.0/src/markdown_checker/reports/format_output.py +86 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/__init__.py +2 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/extract_links.py +26 -27
- markdown_checker-1.1.0/src/markdown_checker/utils/github_env.py +57 -0
- markdown_checker-1.1.0/src/markdown_checker/utils/url_pipeline.py +254 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/smoke/test_cli_smoke.py +1 -1
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checker.py +115 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_broken_urls.py +46 -79
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_cli.py +1 -1
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_models_base.py +3 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_models_url.py +41 -64
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_utils_extract_links.py +54 -0
- markdown_checker-1.1.0/tests/test_utils_format_output.py +139 -0
- markdown_checker-1.1.0/tests/test_utils_github_env.py +105 -0
- markdown_checker-1.1.0/tests/test_utils_url_pipeline.py +347 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/uv.lock +360 -557
- markdown_checker-1.0.3/src/markdown_checker/checker.py +0 -84
- markdown_checker-1.0.3/src/markdown_checker/checks/base.py +0 -36
- markdown_checker-1.0.3/src/markdown_checker/checks/broken_urls.py +0 -86
- markdown_checker-1.0.3/src/markdown_checker/reports/format_output.py +0 -58
- markdown_checker-1.0.3/tests/test_utils_format_output.py +0 -86
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/LICENSE +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/README.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/CODE_OF_CONDUCT.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/CONTRIBUTING.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/checker.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/checks.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/cli.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/api/reports.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/automate.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/changes.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/license.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/docs/usage.rst +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/__init__.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/models/__init__.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/py.typed +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/__init__.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/base.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/markdown.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/paths/broken.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/paths/tracking.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/urls/broken.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/urls/locale.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/reports/templates/urls/tracking.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/list_files.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/utils/spinner.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/conftest.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample1.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample2.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample3.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/resources/sample4.md +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_broken_paths.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_locale.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_registry.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_checks_tracking.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_models_path.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_reports.py +0 -0
- {markdown_checker-1.0.3 → markdown_checker-1.1.0}/tests/test_utils_list_files.py +0 -0
- {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
|
+
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.
|
|
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
|
|
|
@@ -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"),
|
|
@@ -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
|
|
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.
|
|
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.
|
|
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",
|
|
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
|
|
2
|
+
markdown-checker - a markdown link validation reporting tool.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = "1.0
|
|
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)
|
{markdown_checker-1.0.3 → markdown_checker-1.1.0}/src/markdown_checker/checks/broken_paths.py
RENAMED
|
@@ -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)
|