diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d8786e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contribution Guide + +## Pull Request +We welcome anyone who can adhere to our [code of conduct.](CODE_OF_CONDUCT_JP.md) + +## Test +### Code Style +Please adhere to the `black` style guide. Please adhere to the `flake8` lint rules. \ No newline at end of file diff --git a/Pipfile b/Pipfile index 8d3369a..5841a7f 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,6 @@ verify_ssl = true name = "pypi" [packages] -tqdm = "*" dataclasses-json = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index dab7dd2..848f2a1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "abfbd1f0c8a48d405bf75c514ae132d96e9bde10fb1658471b8b1ea4f8100f2f" + "sha256": "9e66e1ea2350343ebaec3b0d2f37b9e39c682de843ac2ca9aab8113356b6a299" }, "pipfile-spec": 6, "requires": { @@ -16,14 +16,6 @@ ] }, "default": { - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", - "version": "==0.4.6" - }, "dataclasses-json": { "hashes": [ "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", @@ -57,15 +49,6 @@ "markers": "python_version >= '3.8'", "version": "==25.0" }, - "tqdm": { - "hashes": [ - "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", - "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==4.67.1" - }, "typing-extensions": { "hashes": [ "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", @@ -486,28 +469,28 @@ }, "uv": { "hashes": [ - "sha256:110fd2062dccc4825f96cef0007f6bbb6f5da117af50415bed6f6dcd5e1e3603", - "sha256:21981bc859802c94d4b8f026b8734d39e8146baac703f1e3eab2e2d87d65ca8c", - "sha256:3c133e34feca0823d952a1d323d1d47f2b35e13154de5ca293d188f5b8792a62", - "sha256:3c2ddc0bca2e4b7e31623a17d60ea5f8d1ee4ff3ee27ddbf884e089e57cd0c93", - "sha256:4e256d3cc542f73435b27a3d0bf2a6b0f9d2dd6dd5c8df1a8028834deb896819", - "sha256:516d1c5549912696ba68376d2652661ed327199a9ec15763f7baa29bc75b35ec", - "sha256:55ecddf19314f7da5b8dea6bcfcc86f5769dd7d22106c731e8e6cfbf3fa6b98d", - "sha256:5e7a86eb5edbb9064b1401522eb10c5bd25ff56c04cd0ed253d0cd088a448bef", - "sha256:763cf33c7c5ab490323fab667b24f66412c3c3ca58e86b56e13fc0109d031a1b", - "sha256:780a7af4a7dfb0894a8d367b5c3a48f926480f465a34aa4a8633d6e4f521e517", - "sha256:84128ca76a665fe0584bff6ef913741d5615a4187c1aaed81739d519e669cfbd", - "sha256:8c8969d54c9f603107445a23f36fba03a0dfa4c75b3ada2faf94ed371f26f3a4", - "sha256:92d4b58ef810eaf5edf95c607d613ff240ab903ee599af1d687f891ab64b4129", - "sha256:9ee9027eb98cf5a99d9a3d5ddab7048745e2e49d572869044f66772e17f57792", - "sha256:a02b27e00afe0d7908fbb9ec61ad5338508e02356138a8a718af239fe773dd2e", - "sha256:b0a0c4594871de9e8b12c9e10982dc83e2414b712a34e84860fac9fbc8510c5a", - "sha256:c9a326f04b670f1af2d9fa90e50af57a9a872a8dc90bb377d0c27b2030438ffc", - "sha256:d4903e73f5e281ce5c3def60014bef93d2d1a148901653e222470ce54987f4a1", - "sha256:dfcc3cd5765158e82a0f52066462e378887aac01b347b588907fe3290af92356" + "sha256:1f8afc13b3b94bce1e72514c598d41623387b2b61b68d7dbce9a01a0d8874860", + "sha256:48548a23fb5a103b8955dfafff7d79d21112b8e25ce5ff25e3468dc541b20e83", + "sha256:4907a696c745703542ed2559bdf5380b92c8b1d4bf290ebfed45bf9a2a2c6690", + "sha256:605a7a57f508aabd029fc0c5ef5c60a556f8c50d32e194f1a300a9f4e87f18d4", + "sha256:6a31b0bd4eaec59bf97816aefbcd75cae4fcc8875c4b19ef1846b7bff3d67c70", + "sha256:70137a46675bbecf3a8b43d292a61767f1b944156af3d0f8d5986292bd86f6cf", + "sha256:7d414cfa410f1850a244d87255f98d06ca61cc13d82f6413c4f03e9e0c9effc7", + "sha256:803f85cf25ab7f1fca10fe2e40a1b9f5b1d48efc25efd6651ba3c9668db6a19e", + "sha256:8abfb7d4b136de3e92dd239ea9a51d4b7bbb970dc1b33bec84d08facf82b9a6e", + "sha256:8cc86940d9b3a425575f25dc45247be2fb31f7fed7bf3394ae9daadd466e5b80", + "sha256:92ff773aa4193148019533c55382c2f9c661824bbf0c2e03f12aeefc800ede57", + "sha256:9ef1982295e5aaf909a9668d6fb6abfc5089666c699f585a36f3a67f1a22916a", + "sha256:af5fd91eecaa04b4799f553c726307200f45da844d5c7c5880d64db4debdd5dc", + "sha256:b5af9117bab6c4b3a1cacb0cddfb3cd540d0adfb13c7b8a9a318873cf2d07e52", + "sha256:c65a024ad98547e32168f3a52360fe73ff39cd609a8fb9dd2509aac91483cfc8", + "sha256:cb680948e678590b5960744af2ecea6f2c0307dbb74ac44daf5c00e84ad8c09f", + "sha256:d901269e1db72abc974ba61d37be6e56532e104922329e0b553d9df07ba224be", + "sha256:e97906ca1b90dac91c23af20e282e2e37c8eb80c3721898733928a295f2defda", + "sha256:edc14143d0ba086a7da4b737a77746bb36bc00e3d26466f180ea99e3bf795171" ], "markers": "python_version >= '3.8'", - "version": "==0.9.10" + "version": "==0.9.11" }, "virtualenv": { "hashes": [ diff --git a/README.md b/README.md index 0a69fcd..03e9d51 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ # linkstat +[![test-lint-format](https://github.com/DogFortune/linkstat/actions/workflows/lint-test-format.yml/badge.svg?branch=main)](https://github.com/DogFortune/linkstat/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +_linkstat_ is a script that verifies the connectivity of links documented in the documentation. By detecting broken links early, it maintains the integrity of the documentation. +Currently, only Markdown files (*.md) are supported. + +## Caution +This library accesses services during runtime, so executing it in large quantities will cause load on the target service. When performing functional verification or integrating into CI/CD, please ensure the load on the linked service is minimized as much as possible. + +## Install + +```sh +pip install linkstat +``` + +## Usage + +```sh +linkstat {source_file_or_directory} +``` + +## Output + +You can output reports in JSON format by using the option. + +```sh +linkstat --report-json {path} {source_file_or_directory} +``` + +## Contribute +[Guideline](CONTRIBUTING.md) \ No newline at end of file diff --git a/README_JP.md b/README_JP.md index 2cc880f..e2e748b 100644 --- a/README_JP.md +++ b/README_JP.md @@ -1,11 +1,14 @@ # linkstat -
-[![test-lint-format](https://github.com/DogFortune/linkstat/actions/workflows/lint-test-format.yml/badge.svg?branch=main)](https://github.com/DogFortune/linkstat/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -
+ +[![test-lint-format](https://github.com/DogFortune/linkstat/actions/workflows/lint-test-format.yml/badge.svg)](https://github.com/DogFortune/linkstat/actions/workflows/lint-test-format.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) _linkstat_ はドキュメントに記載されているリンクの疎通確認を行うスクリプトです。リンク切れの早期発見を行う事でドキュメントの健全性を保ちます。 現在対応しているのはMarkdownファイル(*.md)のみです。 +## 注意 +本ライブラリは実行時にサービスへアクセスするため、大量に実行すると相手サービスに負荷が発生します。動作確認及びCI/CDに取り込む場合は、リンク先のサービス負荷は限りなく少なくして下さい。 + ## インストール ```sh @@ -18,8 +21,6 @@ pip install linkstat linkstat {source_file_or_directory} ``` -パスを指定しない場合はカレントディレクトリを検査対象とします。 - ## 出力 オプションを使用することでJSON形式のレポートを出力できます。 diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index b4e25cd..8d41098 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -30,11 +30,6 @@ The MIT License (MIT) Copyright (c) 2018 Łukasz Langa https://github.com/psf/black/blob/43135e9fafccbca723910a45aa62f0f182e85e5f/LICENSE -## tqdm -MPL v2.0 and MIT -Copyright (c) 2013 noamraph -https://github.com/tqdm/tqdm/blob/0ed5d7f18fa3153834cbac0aa57e8092b217cc16/LICENCE - ## Dataclasses JSON The MIT License Copyright (c) 2019 Charles Li and contributors diff --git a/pyproject.toml b/pyproject.toml index 500e7b3..f52b211 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ description = "Perform connectivity checks on URLs listed in the Markdown" license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.10" -version = "0.0.2" -dependencies = ["tqdm", "dataclasses-json"] +version = "1.0.0" +dependencies = ["dataclasses-json"] keywords = ["check", "url", "link"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3 :: Only", diff --git a/src/linkstat/analyzer.py b/src/linkstat/analyzer.py index 5c9667a..9a6fe65 100644 --- a/src/linkstat/analyzer.py +++ b/src/linkstat/analyzer.py @@ -5,7 +5,6 @@ from linkstat.reporter import ReportData from dataclasses import dataclass import re -from tqdm import tqdm URL_PATTERN = r'https?://[^\s\)\]>"]+' URL_RE = re.compile(URL_PATTERN) @@ -50,29 +49,28 @@ def request(url: str) -> AnalyzeResponse: def check_links(links: dict[str, URLInfo]) -> list[ReportData]: - """URLの疎通確認を行います。確認を行うのは重複していないものだけです。 + """URLの疎通確認を行います。確認を行うのは重複していないものだけ。 :param links: URLリスト :type links: dict[str, URLInfo] - :return: 確認結果 + :return: 確認結果(重複したURLは除く) :rtype: list[ReportData] """ results = [] - with tqdm(links.items()) as links_prog: - for file_path, link_items in links_prog: - links_prog.set_description(file_path) - for item in tqdm(link_items): - if not item.duplicate: - res = request(item.url) - data = ReportData( - file_path, - item.line, - item.url, - res.result, - res.code, - res.reason, - ) - results.append(data) + for file_path, link_items in links.items(): + for item in link_items: + if not item.duplicate: + res = request(item.url) + data = ReportData( + file_path, + item.line, + item.url, + res.result, + res.code, + res.reason, + ) + print(f"{data.url}: {data.result}") + results.append(data) return results diff --git a/src/linkstat/app.py b/src/linkstat/app.py index f42781d..75e72a0 100644 --- a/src/linkstat/app.py +++ b/src/linkstat/app.py @@ -1,4 +1,3 @@ -import os from pathlib import Path from . import analyzer from . import reporter @@ -19,7 +18,7 @@ def __output(data: list[ReportData], format: OutputType, args): """ match format: case OutputType.Console: - line = reporter.console(data) + line = reporter.get_summary_message(data) print(line) case OutputType.Json: output_path = args.report_json @@ -46,7 +45,7 @@ def __format_setting(args) -> OutputType: def create_parser(): parser = argparse.ArgumentParser() - parser.add_argument("src", default=os.environ.get("SRC_DIR", ".")) + parser.add_argument("src") parser.add_argument( "--report-json", type=str, help="Create json report file at given path" ) @@ -60,6 +59,8 @@ def main(args=None): format = __format_setting(parsed_args) src = parsed_args.src + start_msg = reporter.get_fill_plain_message(" linkstat start ") + print(start_msg) files = analyzer.search(src) links = analyzer.extract_url(files) report_data_list = analyzer.check_links(links) diff --git a/src/linkstat/reporter.py b/src/linkstat/reporter.py index 03dea75..5ef9e8f 100644 --- a/src/linkstat/reporter.py +++ b/src/linkstat/reporter.py @@ -1,9 +1,10 @@ from dataclasses import dataclass from dataclasses_json import dataclass_json -from pprint import pformat +from linkstat.enums import Result from typing import List import json import os +import shutil @dataclass_json @@ -12,9 +13,9 @@ class ReportData: file: str line: int url: str - result: str + result: Result code: int - reason: str + reason: str | None @dataclass_json @@ -31,10 +32,78 @@ def default(self, obj): return super().default(obj) -def console(data: list[ReportData]): - # TODO: 出力形式は仮でpformatを設定中。 - line = pformat(data) - return line +class Colors: + """カラーコード""" + + RED = "\033[91m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + RESET = "\033[0m" + + +def get_fill_plain_message(msg: str) -> str: + """区切り線が入ったメッセージテキストを作成します。 + メソッド自体はメッセージを作るだけで、出力は呼び出し側が行って下さい。 + + :param msg: _description_ + :type msg: str + :return: _description_ + :rtype: str + """ + fill_char = "=" + terminal_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if terminal_width < 40: + terminal_width = 80 + + total_fill = terminal_width - len(msg) + left_fill = total_fill // 2 + right_fill = total_fill - left_fill + + start_message = f"{fill_char*left_fill}{msg}{fill_char*right_fill}" + return start_message + + +def get_summary_message(data: list[ReportData]): + """レポート内容を元にサマリーを作成します。 + チェックしたURLの数、OK,NGの数、NGのものはURLを出す。 + + :param data: _description_ + :type data: list[ReportData] + :return: _description_ + :rtype: _type_ + """ + total_count = len(data) + ok_count = sum(item.result == Result.OK for item in data) + ng_items = [item for item in data if item.result == Result.NG] + + total_part = f"{Colors.GREEN}{total_count} Total{Colors.RESET}" + ok_part = f"{Colors.GREEN}{ok_count} OK{Colors.RESET}" + + color_message = f" {total_part}, {ok_part}" + plain_message = f" {total_count} Total, {ok_count} OK" + summary_message = "" + + if (ng_count := len(ng_items)) == 0: + fill_char = f"{Colors.GREEN}={Colors.RESET}" + else: + print(get_fill_plain_message(" FAILURES ")) + ng_detail = "\n".join([f"{item.url}: {item.reason}" for item in ng_items]) + summary_message += f"{ng_detail}" + "\n" + ng_part = f"{Colors.RED}{ng_count} NG{Colors.RESET}" + fill_char = f"{Colors.RED}={Colors.RESET}" + color_message += f", {ng_part} " + plain_message += f", {ng_count} NG " + + terminal_width = shutil.get_terminal_size(fallback=(80, 24)).columns + if terminal_width < 40: + terminal_width = 80 + + total_fill = terminal_width - len(plain_message) + left_fill = total_fill // 2 + right_fill = total_fill - left_fill + + summary_message += f"{fill_char*left_fill}{color_message}{fill_char*right_fill}" + return summary_message def dump_json(data: list[ReportData], output_path: str): diff --git a/tests/conftest.py b/tests/conftest.py index 71ff3d8..d67afa6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,8 @@ from urllib.error import HTTPError, URLError -@pytest.fixture(scope="session", autouse=True) -def check_mock_server(): +@pytest.fixture() +def use_mock_server(): """テスト実行前にモックサーバーの起動を確認""" mock_server_url = "http://localhost:8000/get" diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index bf6bc42..3faad4d 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -13,16 +13,25 @@ pytest.param("http://127.0.0.1:800", "NG", None), ], ) +@pytest.mark.usefixtures("use_mock_server") def test_request(url: str, expected_result: str, expected_status_code: int): - # アクセスチェックした時に想定しているリクエストが返ってくる事。 - # 200系だけTrueで、それ以外はFalseで返ってくる事。 - # URLErrorが発生した(レスポンスが無く、そもそも接続できなかった)場合はFalseでステータスコードがNoneとなる事。 + """URLチェックのレスポンスの形式と挙動テスト。 + 想定している形式で返ってきている事と、URLErrorが発生する場合はステータスコードがNoneになっている事。 + + :param url: _description_ + :type url: str + :param expected_result: _description_ + :type expected_result: str + :param expected_status_code: _description_ + :type expected_status_code: int + """ res = analyzer.request(url) assert type(res) is analyzer.AnalyzeResponse assert res.result == expected_result assert res.code == expected_status_code assert res.url == url + # NGの場合は理由が必ず入っている事 if res.result.upper() == "NG": assert res.reason is not None diff --git a/tests/test_app.py b/tests/test_app.py index 4ea939d..fdfd547 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -8,10 +8,15 @@ class TestValid: """正常系""" + @pytest.mark.usefixtures("use_mock_server") def test_main_with_minimal_arguments(self): """環境変数も引数も指定しない一気通貫のテスト""" app.main(["tests/sample_doc/"]) + # def test_awesome(self): + # app.main(["tmp/awesome-main"]) + + @pytest.mark.usefixtures("use_mock_server") def test_main_with_output_json(self): """JSONファイルが出力されている事""" with TemporaryDirectory() as dir: @@ -30,6 +35,7 @@ def test_main_report_path_directory(self): ): app.main(["tests/sample_doc/", "--report-json", str(dir)]) + @pytest.mark.usefixtures("use_mock_server") def test_main_single_file(self): """単体のファイルを指定した場合も正しく動作する事""" app.main(["tests/sample_doc/doc1.md"]) diff --git a/tests/test_reporter.py b/tests/test_reporter.py index 968b849..71d8176 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -1,6 +1,5 @@ import pytest -from linkstat import reporter -from linkstat import analyzer +from linkstat import reporter, analyzer, enums from tempfile import TemporaryDirectory from pathlib import Path import os @@ -22,11 +21,28 @@ def setup_report_data(): class TestValid: """正常系""" - def test_console(self, setup_report_data): - """コンソール出力テスト。文字列が想定している形である事""" - output_line = reporter.console(setup_report_data) + def test_summary(self, setup_report_data): + """サマリー出力テスト。文字列が想定している形である事""" + output_line = reporter.get_summary_message(setup_report_data) assert output_line is not None + assert reporter.Colors.RED in output_line + + def test_summary_all_ok(self): + """OKのものだけだった時はNGが入っておらず文字もグリーンのみである事""" + results_report_data = [] + results_report_data.append( + reporter.ReportData( + "path/to/doc1.md", 2, "https://example.com", enums.Result.OK, 200, None + ) + ) + summary_message = reporter.get_summary_message(results_report_data) + + assert summary_message is not None + assert "NG" not in summary_message + assert reporter.Colors.GREEN in summary_message + assert reporter.Colors.RED not in summary_message + assert reporter.Colors.YELLOW not in summary_message def test_json(self, setup_report_data): with TemporaryDirectory() as dir: