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
+[](https://github.com/DogFortune/linkstat/actions) [](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
-
-[](https://github.com/DogFortune/linkstat/actions) [](https://opensource.org/licenses/MIT)
-
+
+[](https://github.com/DogFortune/linkstat/actions/workflows/lint-test-format.yml)
+[](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: