Markdown-Checker 0.2.5__tar.gz → 1.0.1__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-0.2.5/src/markdown_checker.egg-info → markdown_checker-1.0.1}/PKG-INFO +7 -6
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/README.md +2 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/pyproject.toml +25 -10
- markdown_checker-1.0.1/src/markdown_checker/__init__.py +10 -0
- markdown_checker-1.0.1/src/markdown_checker/checker.py +82 -0
- markdown_checker-1.0.1/src/markdown_checker/checks/__init__.py +16 -0
- markdown_checker-1.0.1/src/markdown_checker/checks/base.py +30 -0
- markdown_checker-1.0.1/src/markdown_checker/checks/broken_paths.py +22 -0
- markdown_checker-1.0.1/src/markdown_checker/checks/broken_urls.py +67 -0
- markdown_checker-1.0.1/src/markdown_checker/checks/locale.py +36 -0
- markdown_checker-1.0.1/src/markdown_checker/checks/tracking.py +51 -0
- markdown_checker-1.0.1/src/markdown_checker/cli.py +231 -0
- markdown_checker-1.0.1/src/markdown_checker/models/__init__.py +6 -0
- markdown_checker-0.2.5/src/markdown_checker/markdown_link_base.py → markdown_checker-1.0.1/src/markdown_checker/models/base.py +6 -7
- markdown_checker-1.0.1/src/markdown_checker/models/config.py +30 -0
- markdown_checker-0.2.5/src/markdown_checker/paths.py → markdown_checker-1.0.1/src/markdown_checker/models/path.py +39 -24
- markdown_checker-1.0.1/src/markdown_checker/models/url.py +64 -0
- markdown_checker-1.0.1/src/markdown_checker/reports/__init__.py +5 -0
- markdown_checker-0.2.5/src/markdown_checker/reports/generator_base.py → markdown_checker-1.0.1/src/markdown_checker/reports/base.py +0 -5
- markdown_checker-1.0.1/src/markdown_checker/reports/format_output.py +58 -0
- markdown_checker-0.2.5/src/markdown_checker/reports/md_reports/generator.py → markdown_checker-1.0.1/src/markdown_checker/reports/markdown.py +19 -14
- markdown_checker-1.0.1/src/markdown_checker/utils/__init__.py +11 -0
- markdown_checker-1.0.1/src/markdown_checker/utils/extract_links.py +76 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/src/markdown_checker/utils/list_files.py +3 -1
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/src/markdown_checker/utils/spinner.py +5 -5
- {markdown_checker-0.2.5 → markdown_checker-1.0.1/src/markdown_checker.egg-info}/PKG-INFO +7 -6
- markdown_checker-1.0.1/src/markdown_checker.egg-info/SOURCES.txt +52 -0
- markdown_checker-1.0.1/src/markdown_checker.egg-info/entry_points.txt +2 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/src/markdown_checker.egg-info/requires.txt +2 -3
- markdown_checker-1.0.1/tests/test_checker.py +151 -0
- markdown_checker-1.0.1/tests/test_checks_broken_paths.py +64 -0
- markdown_checker-1.0.1/tests/test_checks_broken_urls.py +109 -0
- markdown_checker-1.0.1/tests/test_checks_locale.py +85 -0
- markdown_checker-1.0.1/tests/test_checks_registry.py +34 -0
- markdown_checker-1.0.1/tests/test_checks_tracking.py +146 -0
- markdown_checker-1.0.1/tests/test_cli.py +217 -0
- markdown_checker-1.0.1/tests/test_models_base.py +67 -0
- markdown_checker-1.0.1/tests/test_models_path.py +104 -0
- markdown_checker-1.0.1/tests/test_models_url.py +124 -0
- markdown_checker-1.0.1/tests/test_reports.py +106 -0
- markdown_checker-1.0.1/tests/test_utils_extract_links.py +125 -0
- markdown_checker-1.0.1/tests/test_utils_format_output.py +86 -0
- markdown_checker-1.0.1/tests/test_utils_list_files.py +88 -0
- markdown_checker-1.0.1/tests/test_utils_spinner.py +72 -0
- markdown_checker-0.2.5/src/markdown_checker/__init__.py +0 -358
- markdown_checker-0.2.5/src/markdown_checker/reports/__init__.py +0 -0
- markdown_checker-0.2.5/src/markdown_checker/reports/md_reports/__init__.py +0 -0
- markdown_checker-0.2.5/src/markdown_checker/urls.py +0 -57
- markdown_checker-0.2.5/src/markdown_checker/utils/__init__.py +0 -0
- markdown_checker-0.2.5/src/markdown_checker/utils/extract_links.py +0 -61
- markdown_checker-0.2.5/src/markdown_checker/utils/format_output.py +0 -42
- markdown_checker-0.2.5/src/markdown_checker/utils/logging.py +0 -19
- markdown_checker-0.2.5/src/markdown_checker.egg-info/SOURCES.txt +0 -30
- markdown_checker-0.2.5/src/markdown_checker.egg-info/entry_points.txt +0 -2
- markdown_checker-0.2.5/tests/test_markdown_checker.py +0 -135
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/LICENSE +0 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/setup.cfg +0 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/src/markdown_checker/py.typed +0 -0
- {markdown_checker-0.2.5/src/markdown_checker/reports/md_reports → markdown_checker-1.0.1/src/markdown_checker/reports}/templates/paths/broken.md +0 -0
- {markdown_checker-0.2.5/src/markdown_checker/reports/md_reports → markdown_checker-1.0.1/src/markdown_checker/reports}/templates/paths/tracking.md +0 -0
- {markdown_checker-0.2.5/src/markdown_checker/reports/md_reports → markdown_checker-1.0.1/src/markdown_checker/reports}/templates/urls/broken.md +0 -0
- {markdown_checker-0.2.5/src/markdown_checker/reports/md_reports → markdown_checker-1.0.1/src/markdown_checker/reports}/templates/urls/locale.md +0 -0
- {markdown_checker-0.2.5/src/markdown_checker/reports/md_reports → markdown_checker-1.0.1/src/markdown_checker/reports}/templates/urls/tracking.md +0 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/src/markdown_checker.egg-info/dependency_links.txt +0 -0
- {markdown_checker-0.2.5 → markdown_checker-1.0.1}/src/markdown_checker.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: markdown-checker
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.1
|
|
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>
|
|
@@ -37,21 +37,20 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
37
37
|
Classifier: Operating System :: OS Independent
|
|
38
38
|
Classifier: Programming Language :: Python
|
|
39
39
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
40
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
41
40
|
Classifier: Programming Language :: Python :: 3.10
|
|
42
41
|
Classifier: Programming Language :: Python :: 3.11
|
|
43
42
|
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
44
44
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
45
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
46
|
-
Requires-Python: >=3.
|
|
46
|
+
Requires-Python: >=3.10
|
|
47
47
|
Description-Content-Type: text/markdown
|
|
48
48
|
License-File: LICENSE
|
|
49
|
-
Requires-Dist: click
|
|
50
|
-
Requires-Dist:
|
|
49
|
+
Requires-Dist: click==8.3.2
|
|
50
|
+
Requires-Dist: httpx==0.28.1
|
|
51
51
|
Provides-Extra: dev
|
|
52
52
|
Requires-Dist: pre-commit; extra == "dev"
|
|
53
53
|
Requires-Dist: mypy; extra == "dev"
|
|
54
|
-
Requires-Dist: types-requests; extra == "dev"
|
|
55
54
|
Provides-Extra: lint
|
|
56
55
|
Requires-Dist: ruff; extra == "lint"
|
|
57
56
|
Provides-Extra: test
|
|
@@ -93,6 +92,8 @@ pip install markdown-checker
|
|
|
93
92
|
|
|
94
93
|
For more customizations read the docs.
|
|
95
94
|
|
|
95
|
+
> **Note:** When using list parameters (like `--skip-domains`, `--skip-files`, etc.), provide values as comma-separated without spaces: `--skip-domains=example.com,test.com`. Do not use square brackets or spaces after commas. See the [Advanced Usage](https://markdown-checker.readthedocs.io/en/latest/advanced/) documentation for detailed examples.
|
|
96
|
+
|
|
96
97
|
## Using `markdown-checker` in GitHub Actions
|
|
97
98
|
|
|
98
99
|
You can run this tool within a GitHub workflow using the [action-check-markdown](https://github.com/marketplace/actions/check-markdown) GitHub action.
|
|
@@ -27,6 +27,8 @@ pip install markdown-checker
|
|
|
27
27
|
|
|
28
28
|
For more customizations read the docs.
|
|
29
29
|
|
|
30
|
+
> **Note:** When using list parameters (like `--skip-domains`, `--skip-files`, etc.), provide values as comma-separated without spaces: `--skip-domains=example.com,test.com`. Do not use square brackets or spaces after commas. See the [Advanced Usage](https://markdown-checker.readthedocs.io/en/latest/advanced/) documentation for detailed examples.
|
|
31
|
+
|
|
30
32
|
## Using `markdown-checker` in GitHub Actions
|
|
31
33
|
|
|
32
34
|
You can run this tool within a GitHub workflow using the [action-check-markdown](https://github.com/marketplace/actions/check-markdown) GitHub action.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "markdown-checker"
|
|
3
3
|
description= "A markdown link validation reporting tool."
|
|
4
|
-
version = "0.
|
|
4
|
+
version = "1.0.1"
|
|
5
5
|
authors = [{ name = "John Aziz", email = "johnaziz269@gmail.com" }]
|
|
6
6
|
maintainers = [{ name = "John Aziz", email = "johnaziz269@gmail.com" }]
|
|
7
7
|
license = {file = "LICENSE"}
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"click",
|
|
13
|
-
"
|
|
12
|
+
"click==8.3.2",
|
|
13
|
+
"httpx==0.28.1",
|
|
14
14
|
]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -19,10 +19,10 @@ classifiers = [
|
|
|
19
19
|
"Operating System :: OS Independent",
|
|
20
20
|
"Programming Language :: Python",
|
|
21
21
|
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
-
"Programming Language :: Python :: 3.9",
|
|
23
22
|
"Programming Language :: Python :: 3.10",
|
|
24
23
|
"Programming Language :: Python :: 3.11",
|
|
25
24
|
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
26
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
27
|
"Topic :: Software Development :: Quality Assurance",
|
|
28
28
|
]
|
|
@@ -41,7 +41,6 @@ Releases = "https://github.com/john0isaac/markdown-checker/releases"
|
|
|
41
41
|
dev = [
|
|
42
42
|
"pre-commit",
|
|
43
43
|
"mypy",
|
|
44
|
-
"types-requests",
|
|
45
44
|
]
|
|
46
45
|
lint = [
|
|
47
46
|
"ruff"
|
|
@@ -60,18 +59,34 @@ docs = [
|
|
|
60
59
|
[tool.ruff]
|
|
61
60
|
lint.select = ["E", "F", "I", "UP"]
|
|
62
61
|
line-length = 120
|
|
63
|
-
target-version = "
|
|
62
|
+
target-version = "py310"
|
|
64
63
|
src = ["src"]
|
|
65
64
|
lint.isort.known-first-party = ["markdown_checker"]
|
|
66
65
|
output-format = "full"
|
|
67
66
|
|
|
68
67
|
[tool.mypy]
|
|
68
|
+
strict_optional = true
|
|
69
|
+
warn_return_any = true
|
|
70
|
+
warn_unused_configs = true
|
|
71
|
+
disallow_untyped_defs = true
|
|
69
72
|
check_untyped_defs = true
|
|
70
|
-
python_version = 3.
|
|
71
|
-
exclude = [".venv/*"]
|
|
73
|
+
python_version = '3.10'
|
|
74
|
+
exclude = [".venv/*", "tests/*", "smoke/*"]
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
markers = [
|
|
78
|
+
"smoke: end-to-end smoke tests that run the CLI against sample files",
|
|
79
|
+
]
|
|
80
|
+
addopts = "-ra"
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
pythonpath = ['src']
|
|
83
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
84
|
+
|
|
85
|
+
[tool.coverage.report]
|
|
86
|
+
show_missing = true
|
|
72
87
|
|
|
73
88
|
[project.scripts]
|
|
74
|
-
markdown-checker = "markdown_checker:
|
|
89
|
+
markdown-checker = "markdown_checker:main"
|
|
75
90
|
|
|
76
91
|
[build-system]
|
|
77
92
|
requires = ["setuptools", "wheel"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
markdown-checker — a markdown link validation reporting tool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.1"
|
|
6
|
+
|
|
7
|
+
from markdown_checker.cli import main
|
|
8
|
+
from markdown_checker.models import Config, MarkdownLinkBase, MarkdownPath, MarkdownURL
|
|
9
|
+
|
|
10
|
+
__all__ = ["Config", "main", "MarkdownLinkBase", "MarkdownPath", "MarkdownURL", "__version__"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from markdown_checker.checks import REGISTRY
|
|
6
|
+
from markdown_checker.models import Config, MarkdownLinkBase
|
|
7
|
+
from markdown_checker.utils import get_links_from_md_file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CheckResult:
|
|
12
|
+
"""Result of running a check on one or more files."""
|
|
13
|
+
|
|
14
|
+
issues: list[tuple[Path, list[MarkdownLinkBase]]] = field(default_factory=list)
|
|
15
|
+
links_checked: int = 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_issues(
|
|
19
|
+
func: str,
|
|
20
|
+
file_path: Path,
|
|
21
|
+
config: Config,
|
|
22
|
+
) -> tuple[list[MarkdownLinkBase], int]:
|
|
23
|
+
"""
|
|
24
|
+
Detect issues in a single markdown file using the named check.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
func (str): Name of the check to run (must be a key in REGISTRY).
|
|
28
|
+
file_path (Path): Path to the markdown file to check.
|
|
29
|
+
config (Config): Runtime configuration for the check.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A tuple of (detected_issues, links_checked_count).
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If func is not a registered check name.
|
|
36
|
+
"""
|
|
37
|
+
check = REGISTRY.get(func)
|
|
38
|
+
if check is None:
|
|
39
|
+
raise ValueError(f"Unknown check function: {func!r}. Available: {list(REGISTRY)}")
|
|
40
|
+
|
|
41
|
+
all_links = get_links_from_md_file(file_path)
|
|
42
|
+
if not all_links.paths and not all_links.urls:
|
|
43
|
+
return [], 0
|
|
44
|
+
|
|
45
|
+
detected_issues = check.run(links=all_links, config=config)
|
|
46
|
+
|
|
47
|
+
links_count = len(all_links.paths) if check.link_type == "paths" else len(all_links.urls)
|
|
48
|
+
return detected_issues, links_count
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_check_on_files(
|
|
52
|
+
func: str,
|
|
53
|
+
files_paths: list[Path],
|
|
54
|
+
config: Config,
|
|
55
|
+
progress_callback: Callable[[], None],
|
|
56
|
+
) -> CheckResult:
|
|
57
|
+
"""
|
|
58
|
+
Run a named check across multiple files.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
func: Name of the check to run (must be a key in REGISTRY).
|
|
62
|
+
files_paths: List of markdown file paths to check.
|
|
63
|
+
config: Runtime configuration for the check.
|
|
64
|
+
progress_callback: Callback invoked after each file is checked.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A CheckResult with per-file issues and total links checked.
|
|
68
|
+
"""
|
|
69
|
+
check = REGISTRY.get(func)
|
|
70
|
+
if check is None:
|
|
71
|
+
raise ValueError(f"Unknown check function: {func!r}. Available: {list(REGISTRY)}")
|
|
72
|
+
|
|
73
|
+
result = CheckResult()
|
|
74
|
+
|
|
75
|
+
for file_path in files_paths:
|
|
76
|
+
detected_issues, links_count = detect_issues(func=func, file_path=file_path, config=config)
|
|
77
|
+
result.links_checked += links_count
|
|
78
|
+
if detected_issues:
|
|
79
|
+
result.issues.append((file_path, detected_issues))
|
|
80
|
+
progress_callback()
|
|
81
|
+
|
|
82
|
+
return result
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from markdown_checker.checks.base import BaseCheck
|
|
2
|
+
from markdown_checker.checks.broken_paths import BrokenPathsCheck
|
|
3
|
+
from markdown_checker.checks.broken_urls import BrokenURLsCheck
|
|
4
|
+
from markdown_checker.checks.locale import URLsLocaleCheck
|
|
5
|
+
from markdown_checker.checks.tracking import PathsTrackingCheck, URLsTrackingCheck
|
|
6
|
+
|
|
7
|
+
# Maps the CLI --func argument value to the corresponding check instance.
|
|
8
|
+
REGISTRY: dict[str, BaseCheck] = {
|
|
9
|
+
BrokenPathsCheck.name: BrokenPathsCheck(),
|
|
10
|
+
BrokenURLsCheck.name: BrokenURLsCheck(),
|
|
11
|
+
URLsTrackingCheck.name: URLsTrackingCheck(),
|
|
12
|
+
PathsTrackingCheck.name: PathsTrackingCheck(),
|
|
13
|
+
URLsLocaleCheck.name: URLsLocaleCheck(),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
__all__ = ["REGISTRY", "BaseCheck"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from markdown_checker.models import Config, MarkdownLinkBase
|
|
5
|
+
from markdown_checker.utils.extract_links import MarkdownLinks
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseCheck(ABC):
|
|
9
|
+
"""Abstract base class for all link checks."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
link_type: Literal["paths", "urls"]
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def run(
|
|
16
|
+
self,
|
|
17
|
+
links: MarkdownLinks,
|
|
18
|
+
config: Config | None = None,
|
|
19
|
+
) -> list[MarkdownLinkBase]:
|
|
20
|
+
"""
|
|
21
|
+
Run the check against the extracted links.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
links (MarkdownLinks): Extracted URLs and paths from a file.
|
|
25
|
+
config: Runtime configuration for the check run.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
List of links that failed the check, each with an .issue set.
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from markdown_checker.checks.base import BaseCheck
|
|
2
|
+
from markdown_checker.models import Config, MarkdownLinkBase
|
|
3
|
+
from markdown_checker.utils.extract_links import MarkdownLinks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BrokenPathsCheck(BaseCheck):
|
|
7
|
+
"""Check for relative paths in markdown files that do not exist on disk."""
|
|
8
|
+
|
|
9
|
+
name = "check_broken_paths"
|
|
10
|
+
link_type = "paths"
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
self,
|
|
14
|
+
links: MarkdownLinks,
|
|
15
|
+
config: Config | None = None,
|
|
16
|
+
) -> list[MarkdownLinkBase]:
|
|
17
|
+
detected_issues: list[MarkdownLinkBase] = []
|
|
18
|
+
for path in links.paths:
|
|
19
|
+
if not path.exists():
|
|
20
|
+
path.issue = "is broken"
|
|
21
|
+
detected_issues.append(path)
|
|
22
|
+
return detected_issues
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
from functools import partial
|
|
3
|
+
|
|
4
|
+
from markdown_checker.checks.base import BaseCheck
|
|
5
|
+
from markdown_checker.models import Config, MarkdownLinkBase, MarkdownURL
|
|
6
|
+
from markdown_checker.utils.extract_links import MarkdownLinks
|
|
7
|
+
|
|
8
|
+
# Domains known to block automated requests (e.g. Cloudflare 403);
|
|
9
|
+
# always skipped for URL checks.
|
|
10
|
+
_BUILTIN_SKIP_DOMAINS: list[str] = [
|
|
11
|
+
"openai.com",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _check_url(
|
|
16
|
+
url: MarkdownURL,
|
|
17
|
+
skip_domains: list[str],
|
|
18
|
+
skip_urls_containing: list[str],
|
|
19
|
+
timeout: int,
|
|
20
|
+
retries: int,
|
|
21
|
+
) -> MarkdownURL | None:
|
|
22
|
+
"""Thread worker: checks a single URL with its own httpx client."""
|
|
23
|
+
hostname = url.host_name().lower()
|
|
24
|
+
if any(domain.lower() in hostname for domain in skip_domains) or any(
|
|
25
|
+
substring in url.link for substring in skip_urls_containing
|
|
26
|
+
):
|
|
27
|
+
return None
|
|
28
|
+
# Each worker creates its own client via is_alive(client=None).
|
|
29
|
+
if not url.is_alive(timeout=timeout, retries=retries):
|
|
30
|
+
url.issue = "is broken"
|
|
31
|
+
return url
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BrokenURLsCheck(BaseCheck):
|
|
36
|
+
"""Check for URLs in markdown files that return non-2xx responses."""
|
|
37
|
+
|
|
38
|
+
name = "check_broken_urls"
|
|
39
|
+
link_type = "urls"
|
|
40
|
+
|
|
41
|
+
def run(
|
|
42
|
+
self,
|
|
43
|
+
links: MarkdownLinks,
|
|
44
|
+
config: Config | None = None,
|
|
45
|
+
) -> list[MarkdownLinkBase]:
|
|
46
|
+
config = config or Config()
|
|
47
|
+
effective_skip = [*config.skip_domains, *_BUILTIN_SKIP_DOMAINS]
|
|
48
|
+
skip_urls_containing = config.skip_urls_containing
|
|
49
|
+
|
|
50
|
+
worker = partial(
|
|
51
|
+
_check_url,
|
|
52
|
+
skip_domains=effective_skip,
|
|
53
|
+
skip_urls_containing=skip_urls_containing,
|
|
54
|
+
timeout=config.timeout,
|
|
55
|
+
retries=config.retries,
|
|
56
|
+
)
|
|
57
|
+
# NOTE: httpx.Client is NOT thread-safe. Do not share a single client
|
|
58
|
+
# across ThreadPoolExecutor workers. Each worker creates its own
|
|
59
|
+
# client via is_alive(client=None) to avoid RuntimeError crashes.
|
|
60
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
61
|
+
futures = {executor.submit(worker, url): url for url in links.urls}
|
|
62
|
+
results: list[MarkdownLinkBase] = []
|
|
63
|
+
for future in concurrent.futures.as_completed(futures):
|
|
64
|
+
result = future.result()
|
|
65
|
+
if result is not None:
|
|
66
|
+
results.append(result)
|
|
67
|
+
return results
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from markdown_checker.checks.base import BaseCheck
|
|
2
|
+
from markdown_checker.models import Config, MarkdownLinkBase
|
|
3
|
+
from markdown_checker.utils.extract_links import MarkdownLinks
|
|
4
|
+
|
|
5
|
+
# Domains where locale is required in the URL path; always skipped for locale checks.
|
|
6
|
+
_BUILTIN_SKIP_DOMAINS: list[str] = [
|
|
7
|
+
"www.nvidia.com",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class URLsLocaleCheck(BaseCheck):
|
|
12
|
+
"""Check that URLs do not contain a country/language locale segment."""
|
|
13
|
+
|
|
14
|
+
name = "check_urls_locale"
|
|
15
|
+
link_type = "urls"
|
|
16
|
+
|
|
17
|
+
def run(
|
|
18
|
+
self,
|
|
19
|
+
links: MarkdownLinks,
|
|
20
|
+
config: Config | None = None,
|
|
21
|
+
) -> list[MarkdownLinkBase]:
|
|
22
|
+
config = config or Config()
|
|
23
|
+
effective_skip = [*config.skip_domains, *_BUILTIN_SKIP_DOMAINS]
|
|
24
|
+
skip_urls_containing = config.skip_urls_containing
|
|
25
|
+
|
|
26
|
+
detected_issues: list[MarkdownLinkBase] = []
|
|
27
|
+
for url in links.urls:
|
|
28
|
+
hostname = url.host_name().lower()
|
|
29
|
+
if any(domain.lower() in hostname for domain in effective_skip) or any(
|
|
30
|
+
substring in url.link for substring in skip_urls_containing
|
|
31
|
+
):
|
|
32
|
+
continue
|
|
33
|
+
if url.has_locale():
|
|
34
|
+
url.issue = "has locale"
|
|
35
|
+
detected_issues.append(url)
|
|
36
|
+
return detected_issues
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from markdown_checker.checks.base import BaseCheck
|
|
2
|
+
from markdown_checker.models import Config, MarkdownLinkBase
|
|
3
|
+
from markdown_checker.utils.extract_links import MarkdownLinks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class URLsTrackingCheck(BaseCheck):
|
|
7
|
+
"""Check that URLs on configured tracking domains include a tracking ID."""
|
|
8
|
+
|
|
9
|
+
name = "check_urls_tracking"
|
|
10
|
+
link_type = "urls"
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
self,
|
|
14
|
+
links: MarkdownLinks,
|
|
15
|
+
config: Config | None = None,
|
|
16
|
+
) -> list[MarkdownLinkBase]:
|
|
17
|
+
config = config or Config()
|
|
18
|
+
skip_domains = config.skip_domains
|
|
19
|
+
skip_urls_containing = config.skip_urls_containing
|
|
20
|
+
tracking_domains = config.tracking_domains
|
|
21
|
+
|
|
22
|
+
detected_issues: list[MarkdownLinkBase] = []
|
|
23
|
+
for url in links.urls:
|
|
24
|
+
hostname = url.host_name().lower()
|
|
25
|
+
if any(domain.lower() in hostname for domain in skip_domains) or any(
|
|
26
|
+
substring in url.link for substring in skip_urls_containing
|
|
27
|
+
):
|
|
28
|
+
continue
|
|
29
|
+
if any(domain.lower() in hostname for domain in tracking_domains) and not url.has_tracking():
|
|
30
|
+
url.issue = "is missing tracking id"
|
|
31
|
+
detected_issues.append(url)
|
|
32
|
+
return detected_issues
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PathsTrackingCheck(BaseCheck):
|
|
36
|
+
"""Check that relative paths include a tracking ID."""
|
|
37
|
+
|
|
38
|
+
name = "check_paths_tracking"
|
|
39
|
+
link_type = "paths"
|
|
40
|
+
|
|
41
|
+
def run(
|
|
42
|
+
self,
|
|
43
|
+
links: MarkdownLinks,
|
|
44
|
+
config: Config | None = None,
|
|
45
|
+
) -> list[MarkdownLinkBase]:
|
|
46
|
+
detected_issues: list[MarkdownLinkBase] = []
|
|
47
|
+
for path in links.paths:
|
|
48
|
+
if not path.has_tracking():
|
|
49
|
+
path.issue = "is missing tracking id"
|
|
50
|
+
detected_issues.append(path)
|
|
51
|
+
return detected_issues
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from markdown_checker.checker import run_check_on_files
|
|
8
|
+
from markdown_checker.models.config import Config
|
|
9
|
+
from markdown_checker.reports.format_output import format_issues_table
|
|
10
|
+
from markdown_checker.reports.markdown import MarkdownGenerator
|
|
11
|
+
from markdown_checker.utils.list_files import get_files_paths_list
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ListOfStrings(click.Option):
|
|
15
|
+
"""
|
|
16
|
+
Helper class to parse a comma-separated list of strings from the command line.
|
|
17
|
+
|
|
18
|
+
Ref: https://stackoverflow.com/questions/47631914/how-to-pass-several-list-of-arguments-to-click-option
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def type_cast_value(self, ctx, value) -> list[str]: # type: ignore
|
|
22
|
+
try:
|
|
23
|
+
if isinstance(value, str):
|
|
24
|
+
return list(filter(None, value.split(",")))
|
|
25
|
+
elif isinstance(value, list):
|
|
26
|
+
return value
|
|
27
|
+
else:
|
|
28
|
+
raise Exception
|
|
29
|
+
except Exception:
|
|
30
|
+
raise click.BadParameter(str(value))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command()
|
|
34
|
+
@click.argument(
|
|
35
|
+
"src",
|
|
36
|
+
nargs=-1,
|
|
37
|
+
type=click.Path(path_type=Path, exists=True, file_okay=True, dir_okay=True, readable=True),
|
|
38
|
+
metavar="SRC ...",
|
|
39
|
+
required=False,
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"-d",
|
|
43
|
+
"--dir",
|
|
44
|
+
type=click.Path(path_type=Path, exists=True, file_okay=False, dir_okay=True, readable=True),
|
|
45
|
+
help="Path to the root directory to check.",
|
|
46
|
+
required=False,
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"-f",
|
|
50
|
+
"--func",
|
|
51
|
+
type=click.Choice(
|
|
52
|
+
[
|
|
53
|
+
"check_broken_paths",
|
|
54
|
+
"check_broken_urls",
|
|
55
|
+
"check_paths_tracking",
|
|
56
|
+
"check_urls_tracking",
|
|
57
|
+
"check_urls_locale",
|
|
58
|
+
]
|
|
59
|
+
),
|
|
60
|
+
help="Function to be executed.",
|
|
61
|
+
required=True,
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"-ext",
|
|
65
|
+
"--extensions",
|
|
66
|
+
cls=ListOfStrings,
|
|
67
|
+
type=list[str],
|
|
68
|
+
default=[".md", ".ipynb"],
|
|
69
|
+
help="File extensions to filter the files.",
|
|
70
|
+
required=False,
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"-td",
|
|
74
|
+
"--tracking-domains",
|
|
75
|
+
cls=ListOfStrings,
|
|
76
|
+
type=list[str],
|
|
77
|
+
default=["github.com", "microsoft.com", "visualstudio.com", "aka.ms", "azure.com"],
|
|
78
|
+
help="List of tracking domains to check.",
|
|
79
|
+
required=False,
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"-sf",
|
|
83
|
+
"--skip-files",
|
|
84
|
+
cls=ListOfStrings,
|
|
85
|
+
type=list[str],
|
|
86
|
+
default=[
|
|
87
|
+
"CODE_OF_CONDUCT.md",
|
|
88
|
+
"SECURITY.md",
|
|
89
|
+
],
|
|
90
|
+
help="List of file names to skip check.",
|
|
91
|
+
required=False,
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"-sd",
|
|
95
|
+
"--skip-domains",
|
|
96
|
+
cls=ListOfStrings,
|
|
97
|
+
type=list[str],
|
|
98
|
+
default=[],
|
|
99
|
+
help="List of domains to skip check.",
|
|
100
|
+
required=False,
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"-suc",
|
|
104
|
+
"--skip-urls-containing",
|
|
105
|
+
cls=ListOfStrings,
|
|
106
|
+
type=list[str],
|
|
107
|
+
default=[],
|
|
108
|
+
help="List of urls to skip check.",
|
|
109
|
+
required=False,
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"-gu",
|
|
113
|
+
"--guide-url",
|
|
114
|
+
type=str,
|
|
115
|
+
help="Full url of your contributing guide.",
|
|
116
|
+
required=False,
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"-to",
|
|
120
|
+
"--timeout",
|
|
121
|
+
type=click.IntRange(0, 50),
|
|
122
|
+
default=20,
|
|
123
|
+
help="Timeout in seconds for the requests.",
|
|
124
|
+
required=False,
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"-rt",
|
|
128
|
+
"--retries",
|
|
129
|
+
type=click.IntRange(0, 10),
|
|
130
|
+
default=3,
|
|
131
|
+
help="Number of retries for the requests.",
|
|
132
|
+
required=False,
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"-o",
|
|
136
|
+
"--output-file-name",
|
|
137
|
+
type=str,
|
|
138
|
+
default="comment",
|
|
139
|
+
help="Name of the output file.",
|
|
140
|
+
required=False,
|
|
141
|
+
)
|
|
142
|
+
@click.version_option(
|
|
143
|
+
message=(f"%(prog)s, %(version)s\nPython ({platform.python_implementation()}) {platform.python_version()}"),
|
|
144
|
+
)
|
|
145
|
+
@click.pass_context
|
|
146
|
+
def main(
|
|
147
|
+
ctx: click.Context,
|
|
148
|
+
src: tuple[Path, ...],
|
|
149
|
+
dir: Path | None,
|
|
150
|
+
func: str,
|
|
151
|
+
guide_url: str | None,
|
|
152
|
+
extensions: list[str],
|
|
153
|
+
skip_files: list[str],
|
|
154
|
+
skip_domains: list[str],
|
|
155
|
+
skip_urls_containing: list[str],
|
|
156
|
+
tracking_domains: list[str],
|
|
157
|
+
timeout: int,
|
|
158
|
+
retries: int,
|
|
159
|
+
output_file_name: str,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""A markdown link validation reporting tool."""
|
|
162
|
+
if src:
|
|
163
|
+
files_paths = []
|
|
164
|
+
for p in src:
|
|
165
|
+
if p.is_dir():
|
|
166
|
+
_, dir_files = get_files_paths_list(p, extensions)
|
|
167
|
+
files_paths.extend(dir_files)
|
|
168
|
+
elif p.suffix.lower() in extensions:
|
|
169
|
+
files_paths.append(p)
|
|
170
|
+
elif dir is not None:
|
|
171
|
+
_, files_paths = get_files_paths_list(dir, extensions)
|
|
172
|
+
else:
|
|
173
|
+
raise click.UsageError("Either SRC or --dir must be provided.")
|
|
174
|
+
|
|
175
|
+
# remove files from skip_files list
|
|
176
|
+
files_paths = [file_path for file_path in files_paths if file_path.name not in skip_files]
|
|
177
|
+
|
|
178
|
+
config = Config(
|
|
179
|
+
skip_domains=skip_domains,
|
|
180
|
+
skip_urls_containing=skip_urls_containing,
|
|
181
|
+
tracking_domains=tracking_domains,
|
|
182
|
+
timeout=timeout,
|
|
183
|
+
retries=retries,
|
|
184
|
+
output_mode="ci" if os.getenv("CI", "false") == "true" else "local",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
with click.progressbar(length=len(files_paths), label="Checking", hidden=config.output_mode == "ci") as bar:
|
|
188
|
+
check_result = run_check_on_files(
|
|
189
|
+
func=func,
|
|
190
|
+
files_paths=files_paths,
|
|
191
|
+
config=config,
|
|
192
|
+
progress_callback=lambda: bar.update(1),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
click.echo(
|
|
196
|
+
click.style(f"\n🔍 Checked {check_result.links_checked} links in {len(files_paths)} files.", fg="blue"),
|
|
197
|
+
err=False,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if check_result.issues:
|
|
201
|
+
formatted_output = format_issues_table(check_result.issues, config.output_mode)
|
|
202
|
+
generator = MarkdownGenerator(contributing_guide_url=guide_url, output_file_name=output_file_name)
|
|
203
|
+
generator.generate(func, formatted_output)
|
|
204
|
+
|
|
205
|
+
all_issues = [issue for _, issues in check_result.issues for issue in issues]
|
|
206
|
+
click.echo(click.style(f"😭 Found {len(all_issues)} issues in the following files:", fg="red"), err=True)
|
|
207
|
+
for markdown_path in all_issues:
|
|
208
|
+
if config.output_mode == "ci":
|
|
209
|
+
# Ref: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-a-warning-message
|
|
210
|
+
click.echo(
|
|
211
|
+
click.style(
|
|
212
|
+
f"::error file={markdown_path.file_path},line={markdown_path.line_number}::"
|
|
213
|
+
f"File {markdown_path.file_path}, line {markdown_path.line_number}, "
|
|
214
|
+
f"Link {markdown_path} {markdown_path.issue}.",
|
|
215
|
+
fg="red",
|
|
216
|
+
),
|
|
217
|
+
err=True,
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
click.echo(
|
|
221
|
+
click.style(
|
|
222
|
+
f"\tFile '{markdown_path.file_path.resolve()}', line {markdown_path.line_number}"
|
|
223
|
+
f"\n{markdown_path} {markdown_path.issue}.\n",
|
|
224
|
+
fg="red",
|
|
225
|
+
),
|
|
226
|
+
err=True,
|
|
227
|
+
)
|
|
228
|
+
ctx.exit(1)
|
|
229
|
+
|
|
230
|
+
click.echo(click.style("All files are compliant with the guidelines. 🎉", fg="green"), err=False)
|
|
231
|
+
ctx.exit(0)
|