From db9bf7279bd5c31af03f7eabbea6484c272d4035 Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 01:01:18 +0900 Subject: [PATCH 1/7] update pre-commit configuration to use ruff and remove black and isort --- .pre-commit-config.yaml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3dc5910..8df5aea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,18 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/psf/black - rev: 22.12.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 hooks: - - id: black - types: [python] - language_version: python3.10.11 - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.4 hooks: - - id: isort + - id: ruff + args: [ --fix ] + types: [python] + - id: ruff-format types: [python] - language_version: python3.10.11 From 493f3d58546a691482652be5a7d31b605847b94f Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 01:01:41 +0900 Subject: [PATCH 2/7] refactor: update ruff configuration and improve linting rules --- pyproject.toml | 94 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 592b9f7..257cc79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,31 +32,90 @@ dependencies = [ [tool.hatch.build.targets.wheel] packages = ["src/project", "src"] +[tool.uv] +default-groups = ["dev"] + [tool.ruff] -line-length = 119 target-version = "py313" -exclude = [".git", ".venv", "__pycache__", "data", "dist", "misc", "notebooks", "prof", "tmp", "workspacea", ".tox"] - -[tool.ruff.format] -quote-style = "single" -indent-style = "space" -skip-magic-trailing-comma = false -line-ending = "auto" +line-length = 119 +indent-width = 4 +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] [tool.ruff.lint] -extend-select = [ - "I", # isort +select = ["ALL"] +ignore = [ + "D100", + "D101", + "D102", + "D103", + "D104", + "D105", + "D106", + "D107", + "D203", + "D213", + "G004", + "Q000", + "Q003", + "EM101", + "EM102", + "COM812", + "FBT001", + "FBT002", + "TRY003", + "INP001", +] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", + "PLR2004", ] -# ignores -ignore = ["E501"] [tool.ruff.lint.isort] -section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder", +] split-on-trailing-comma = true -[tool.ruff.lint.pyupgrade] -# Python3.8互換のための設定 -keep-runtime-typing = true +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" [tool.mypy] python_version="3.13" @@ -64,10 +123,9 @@ files = "src" ignore_missing_imports = true disallow_untyped_defs = true no_implicit_optional = true -allow_redefinition = true +allow_redefinition = false show_error_codes = true pretty = true -allow_untyped_globals = true [tool.pytest.ini_options] filterwarnings = ["ignore::DeprecationWarning",] From 6ca393a59213d948c64595047e6d1835f85bb22d Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 01:01:51 +0900 Subject: [PATCH 3/7] fix: update linting commands in tox.ini to improve checks --- tox.ini | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index e7cd650..5e590bb 100644 --- a/tox.ini +++ b/tox.ini @@ -16,8 +16,10 @@ commands = allowlist_externals = ruff mypy + ty skip_install = true commands = - ruff check --output-format=full src/ tests {posargs} - mypy src/ {posargs} + ruff check ./ ruff format --check ./ + mypy . + ;ty check . From e5cb0a9f3d376e196ee81c3be9559283874b369e Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 01:12:27 +0900 Subject: [PATCH 4/7] feat: Enhance project structure and add utility functions - Refactored main entry point in `main.py` to use logging instead of print statements. - Introduced regex utilities in `regex.py` for email, URL, and time pattern matching. - Updated async utilities in `async_utils.py` to include an `AsyncResource` base class for managing concurrency. - Added CLI configuration loading and parsing functions in `cli_utils.py` using Pydantic for type safety. - Implemented file handling utilities for JSON, TOML, YAML, and JSON Lines formats. - Created comprehensive unit tests for JSON, TOML, YAML, and async utilities. - Updated `tox.ini` to specify directories for linting and type checking. --- scripts/main.py | 16 +- src/project/common/regex.py | 46 +++++ src/project/common/utils/async_utils.py | 53 ++++-- src/project/common/utils/cli_utils.py | 50 ++++- src/project/common/utils/file/config.py | 8 +- src/project/common/utils/file/json.py | 17 +- src/project/common/utils/file/jsonlines.py | 18 +- src/project/common/utils/file/toml.py | 15 +- src/project/common/utils/file/yaml.py | 17 +- src/project/common/utils/import_utils.py | 8 +- src/project/common/utils/regex_utils.py | 17 ++ src/project/main.py | 10 +- tests/project/common/utils/file/test_json.py | 88 +++++++++ tests/project/common/utils/file/test_toml.py | 88 +++++++++ tests/project/common/utils/file/test_yaml.py | 91 +++++++++ .../project/common/utils/test_async_utils.py | 179 ++++++++++++++++++ tests/project/common/utils/test_cli_utils.py | 76 ++++++++ .../project/common/utils/test_import_utils.py | 80 ++++++++ .../project/common/utils/test_regex_utils.py | 51 +++++ tests/project/test_env.py | 2 +- tox.ini | 8 +- 21 files changed, 859 insertions(+), 79 deletions(-) create mode 100644 src/project/common/regex.py create mode 100644 src/project/common/utils/regex_utils.py create mode 100644 tests/project/common/utils/file/test_json.py create mode 100644 tests/project/common/utils/file/test_toml.py create mode 100644 tests/project/common/utils/file/test_yaml.py create mode 100644 tests/project/common/utils/test_async_utils.py create mode 100644 tests/project/common/utils/test_cli_utils.py create mode 100644 tests/project/common/utils/test_import_utils.py create mode 100644 tests/project/common/utils/test_regex_utils.py diff --git a/scripts/main.py b/scripts/main.py index ef005ec..ecc3fef 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -1,2 +1,16 @@ +import logging + +from project.env import PACKAGE_DIR + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Sample entry point.""" + logging.basicConfig(level=logging.INFO) + logger.info('Hello, World!') + logger.info('PACKAGE_DIR=%s', PACKAGE_DIR) + + if __name__ == '__main__': - print('Hello, World!') + main() diff --git a/src/project/common/regex.py b/src/project/common/regex.py new file mode 100644 index 0000000..04e57d2 --- /dev/null +++ b/src/project/common/regex.py @@ -0,0 +1,46 @@ +import re +from typing import Final + +from project.common.utils.regex_utils import concat, unmatched_group + +# time regex +TIME_PATTRN: Final[re.Pattern] = re.compile(r'\d+:\d+') + + +# email regex +LOCAL_PART_CHARS = r'[\w\-._]' +DOMAIN_CHARS = r'[\w\-._]' +TLD_CHARS = r'[A-Za-z]' + +local_part = concat([LOCAL_PART_CHARS], without_grouping=True) + r'+' +domain = concat([DOMAIN_CHARS], without_grouping=True) + r'+' +tld = concat([TLD_CHARS], without_grouping=True) + r'+' + +EMAIL_REGEX = local_part + r'@' + domain + r'\.' + tld +EMAIL_PATTERN = re.compile(EMAIL_REGEX) + + +# url regex +SCHEME = r'https?' +CHARS = r'[\w!?/+\-_~;.,*&@#$%()\[\]]' + +url_chars = concat([CHARS], without_grouping=True) + r'+' + +HTTP_URL_REGEX = SCHEME + r'://' + url_chars + +DATA_SCHEME = r'data:' +MEDIATYPE = r'[\w/+.-]+' +BASE64 = r'base64' +DATA = r'[\w+/=]+' + +mediatype_part = unmatched_group(MEDIATYPE) + r'?' +base64_part = unmatched_group(BASE64) + r'?' +data_part = unmatched_group(DATA) + +DATA_URL_REGEX = DATA_SCHEME + mediatype_part + r'(?:;' + base64_part + r')?,' + data_part + +URL_REGEX = concat([HTTP_URL_REGEX, DATA_URL_REGEX]) + +HTTP_URL_PATTERN = re.compile(HTTP_URL_REGEX) +DATA_URL_PATTERN = re.compile(DATA_URL_REGEX) +URL_PATTERN = re.compile(URL_REGEX) diff --git a/src/project/common/utils/async_utils.py b/src/project/common/utils/async_utils.py index 54be58e..0b63ab1 100644 --- a/src/project/common/utils/async_utils.py +++ b/src/project/common/utils/async_utils.py @@ -1,13 +1,13 @@ import asyncio -from typing import Any, Callable +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any -def sync_to_async_func(sync_func: Callable) -> Callable: - """ - 同期関数を非同期関数として使えるように変換する - """ +def sync_to_async_func[R](sync_func: Callable[..., R]) -> Callable[..., Awaitable[R]]: + """Convert a synchronous callable into an asynchronous callable.""" - async def wrapper(*args: Any, **kwargs: Any) -> Any: + async def wrapper(*args: object, **kwargs: object) -> R: return await asyncio.to_thread(sync_func, *args, **kwargs) wrapper.__name__ = sync_func.__name__ @@ -15,12 +15,10 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: return wrapper -def async_to_sync_func(async_func: Callable) -> Callable: - """ - 非同期関数を同期関数として使えるように変換する - """ +def async_to_sync_func[R](async_func: Callable[..., Coroutine[Any, Any, R]]) -> Callable[..., R]: + """Convert an asynchronous callable into a synchronous callable.""" - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: object, **kwargs: object) -> R: return asyncio.run(async_func(*args, **kwargs)) wrapper.__name__ = async_func.__name__ @@ -28,15 +26,30 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return wrapper -async def run_async_function_with_semaphore( - async_func: Callable, concurrency_sema: asyncio.Semaphore | None, *args: Any, **kwargs: Any -) -> Any: - """ - 指定した関数 func を、セマフォで同時実行数を制限して呼び出す関数。 - concurrency_sema が None の場合は制限しない。 - """ +async def run_async_function_with_semaphore[R]( + async_func: Callable[..., Awaitable[R]], + concurrency_sema: asyncio.Semaphore | None, + *args: object, + **kwargs: object, +) -> R: + """Execute async_func with an optional semaphore limiting concurrency.""" if concurrency_sema is not None: async with concurrency_sema: return await async_func(*args, **kwargs) - else: - return await async_func(*args, **kwargs) + return await async_func(*args, **kwargs) + + +class AsyncResource[R](ABC): + """Base class for async resources protected by a semaphore.""" + + def __init__(self, concurrency: int = 1) -> None: + self.semaphore = asyncio.Semaphore(concurrency) + + async def task(self, *args: object, **kwargs: object) -> R: + async with self.semaphore: + return await self.call(*args, **kwargs) + + @abstractmethod + async def call(self, *args: object, **kwargs: object) -> R: + """Execute the concrete asynchronous operation.""" + raise NotImplementedError diff --git a/src/project/common/utils/cli_utils.py b/src/project/common/utils/cli_utils.py index 2c2d7eb..357b4f7 100644 --- a/src/project/common/utils/cli_utils.py +++ b/src/project/common/utils/cli_utils.py @@ -2,22 +2,58 @@ from pathlib import Path from typing import Any +from pydantic import BaseModel + from project.common.utils.file.config import load_config logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def load_cli_config(config_file_path: str | Path | None = None, **kwargs: Any) -> dict[str, Any]: - """ - Load configuration from a file and merge it with runtime arguments. - """ +def load_cli_config(config_file_path: str | Path | None = None, **kwargs: object) -> dict[str, Any]: + """Load configuration from a file and merge it with runtime arguments.""" if config_file_path: - logger.info(f'Loading configuration from {config_file_path}') + logger.info('Loading configuration from %s', config_file_path) merged = load_config(config_file_path) merged.update(kwargs) - logger.info(f'Merged config from file with overrides: {kwargs.keys() if kwargs else {}}') + logger.info('Merged config with overrides: %s', list(kwargs) if kwargs else []) else: - merged = kwargs + merged = dict(kwargs) logger.info('No config file provided; using runtime arguments only.') return merged + + +def load_and_parse_config[T: BaseModel]( + config_class: type[T], + config_file_path: str | Path | None = None, + **kwargs: object, +) -> T: + """Load configuration from file, merge with kwargs, and parse into Pydantic model. + + This function provides type-safe configuration loading by: + 1. Loading config from file (if provided) + 2. Merging with CLI overrides + 3. Validating and parsing into the specified Pydantic model + + Args: + config_class: Pydantic BaseModel subclass to parse into + config_file_path: Path to config file (JSON/YAML/TOML) + **kwargs: CLI overrides to merge with file config + + Returns: + Validated instance of config_class + + Raises: + ValidationError: If configuration is invalid + + Example: + >>> from pydantic import BaseModel, Field + >>> class MyConfig(BaseModel): + ... name: str = Field(...) + ... value: int = Field(default=0) + >>> cfg = load_and_parse_config(MyConfig, 'config.json', value=42) + >>> assert isinstance(cfg, MyConfig) + + """ + raw_config = load_cli_config(config_file_path, **kwargs) + return config_class(**raw_config) diff --git a/src/project/common/utils/file/config.py b/src/project/common/utils/file/config.py index 683b9bf..31f96a4 100644 --- a/src/project/common/utils/file/config.py +++ b/src/project/common/utils/file/config.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, cast +from typing import Any from project.common.utils.file.json import load_json from project.common.utils.file.toml import load_toml @@ -7,9 +7,7 @@ def load_config(path: str | Path) -> dict[str, Any]: - """ - Load configuration from a file (JSON, YAML, or TOML). - """ + """Load configuration from a file (JSON, YAML, or TOML).""" ext = Path(path).suffix.lower() if ext == '.json': @@ -24,4 +22,4 @@ def load_config(path: str | Path) -> dict[str, Any]: if not isinstance(data, dict): raise TypeError(f'Config file {path!r} did not return a dict, got {type(data).__name__}') - return cast(dict[str, Any], data) + return data diff --git a/src/project/common/utils/file/json.py b/src/project/common/utils/file/json.py index 6f1d25d..8f814a9 100644 --- a/src/project/common/utils/file/json.py +++ b/src/project/common/utils/file/json.py @@ -1,21 +1,22 @@ import json from pathlib import Path +from typing import Any +JsonValue = dict[Any, Any] | list[Any] | str | int | float | bool | None -def load_json(path: str | Path) -> dict | list: + +def load_json(path: str | Path) -> JsonValue: with Path(path).open(mode='r', encoding='utf-8') as fin: - data = json.load(fin) - return data + return json.load(fin) def save_as_indented_json( - data: dict | list, + data: JsonValue, path: str | Path, parents: bool = True, exist_ok: bool = True, ) -> None: - path = Path(path) - path.parent.mkdir(parents=parents, exist_ok=exist_ok) - with path.open(mode='w', encoding='utf-8') as fout: + target = Path(path) + target.parent.mkdir(parents=parents, exist_ok=exist_ok) + with target.open(mode='w', encoding='utf-8') as fout: json.dump(data, fout, ensure_ascii=False, indent=4, separators=(',', ': ')) - return diff --git a/src/project/common/utils/file/jsonlines.py b/src/project/common/utils/file/jsonlines.py index 84888fa..59f2a76 100644 --- a/src/project/common/utils/file/jsonlines.py +++ b/src/project/common/utils/file/jsonlines.py @@ -3,23 +3,19 @@ import jsonlines -def load_jsonlines(path: str | Path) -> list[dict]: - data_list = [] - with jsonlines.open(str(path)) as reader: - for data in reader: - data_list.append(data) - return data_list +def load_jsonlines(path: str | Path) -> list[dict[str, object]]: + with jsonlines.open(str(Path(path))) as reader: + return [dict(entry) for entry in reader] def save_as_jsonlines( - data: list[dict], + data: list[dict[str, object]], path: str | Path, parents: bool = True, exist_ok: bool = True, ) -> None: - path = Path(path) - path.parent.mkdir(parents=parents, exist_ok=exist_ok) - with jsonlines.open(str(path), mode='w') as writer: + target = Path(path) + target.parent.mkdir(parents=parents, exist_ok=exist_ok) + with jsonlines.open(str(target), mode='w') as writer: for datum in data: writer.write(datum) - return diff --git a/src/project/common/utils/file/toml.py b/src/project/common/utils/file/toml.py index 476ef35..f17944e 100644 --- a/src/project/common/utils/file/toml.py +++ b/src/project/common/utils/file/toml.py @@ -1,22 +1,21 @@ from pathlib import Path +from typing import Any import toml -def load_toml(path: str | Path) -> dict: +def load_toml(path: str | Path) -> dict[str, Any]: with Path(path).open(mode='r', encoding='utf-8') as fin: - data = toml.load(fin) - return data + return toml.load(fin) def save_as_toml( - data: dict, + data: dict[str, Any], path: str | Path, parents: bool = True, exist_ok: bool = True, ) -> None: - path = Path(path) - path.parent.mkdir(parents=parents, exist_ok=exist_ok) - with path.open(mode='w', encoding='utf-8') as fout: + target = Path(path) + target.parent.mkdir(parents=parents, exist_ok=exist_ok) + with target.open(mode='w', encoding='utf-8') as fout: toml.dump(data, fout) - return diff --git a/src/project/common/utils/file/yaml.py b/src/project/common/utils/file/yaml.py index 127b502..e2db90c 100644 --- a/src/project/common/utils/file/yaml.py +++ b/src/project/common/utils/file/yaml.py @@ -1,22 +1,23 @@ from pathlib import Path +from typing import Any import yaml +YamlValue = dict[str, Any] | list[Any] | str | int | float | bool | None -def load_yaml(path: str | Path) -> dict | list: + +def load_yaml(path: str | Path) -> YamlValue: with Path(path).open(mode='r', encoding='utf-8') as fin: - data = yaml.safe_load(fin) - return data + return yaml.safe_load(fin) def save_as_indented_yaml( - data: dict | list, + data: YamlValue, path: str | Path, parents: bool = True, exist_ok: bool = True, ) -> None: - path = Path(path) - path.parent.mkdir(parents=parents, exist_ok=exist_ok) - with path.open(mode='w', encoding='utf-8') as fout: + target = Path(path) + target.parent.mkdir(parents=parents, exist_ok=exist_ok) + with target.open(mode='w', encoding='utf-8') as fout: yaml.dump(data, fout, allow_unicode=True, indent=4, default_flow_style=False) - return diff --git a/src/project/common/utils/import_utils.py b/src/project/common/utils/import_utils.py index 53ffdb5..c110f25 100644 --- a/src/project/common/utils/import_utils.py +++ b/src/project/common/utils/import_utils.py @@ -1,19 +1,19 @@ import importlib import inspect import sys +from collections.abc import Callable from pathlib import Path -from typing import Callable def import_function(function_file_path: str, function_name: str | None = None) -> Callable: - function_file_path = Path(function_file_path).resolve() - function_name = function_name or function_file_path.stem + resolved_path: Path = Path(function_file_path).resolve() + function_name = function_name or resolved_path.stem project_root = Path.cwd() if str(project_root) not in sys.path: sys.path.append(str(project_root)) - module_path = '.'.join(function_file_path.relative_to(project_root).with_suffix('').parts) + module_path = '.'.join(resolved_path.relative_to(project_root).with_suffix('').parts) module = importlib.import_module(module_path) return getattr(module, function_name) diff --git a/src/project/common/utils/regex_utils.py b/src/project/common/utils/regex_utils.py new file mode 100644 index 0000000..d682ede --- /dev/null +++ b/src/project/common/utils/regex_utils.py @@ -0,0 +1,17 @@ +import re +from collections.abc import Sequence + + +def unmatched_group(_regex: str) -> str: + return r'(?:' + _regex + r')' + + +def concat(regexes: Sequence[str], without_grouping: bool = False) -> str: + _regex = r'|'.join(regexes) + if not without_grouping: + _regex = unmatched_group(_regex) + return _regex + + +def is_match_pattern(text: str, pattern: re.Pattern[str]) -> bool: + return re.search(pattern, text) is not None diff --git a/src/project/main.py b/src/project/main.py index 914f969..ecc3fef 100644 --- a/src/project/main.py +++ b/src/project/main.py @@ -1,9 +1,15 @@ +import logging + from project.env import PACKAGE_DIR +logger = logging.getLogger(__name__) + def main() -> None: - print('Hello, World!') - print(f'PACKAGE_DIR: {PACKAGE_DIR}') + """Sample entry point.""" + logging.basicConfig(level=logging.INFO) + logger.info('Hello, World!') + logger.info('PACKAGE_DIR=%s', PACKAGE_DIR) if __name__ == '__main__': diff --git a/tests/project/common/utils/file/test_json.py b/tests/project/common/utils/file/test_json.py new file mode 100644 index 0000000..46880ff --- /dev/null +++ b/tests/project/common/utils/file/test_json.py @@ -0,0 +1,88 @@ +import json +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest + +from project.common.utils.file.json import load_json, save_as_indented_json + + +@pytest.mark.parametrize( + ('input_data', 'expected_result'), + [ + ('{"key": "value"}', {'key': 'value'}), + ('{"nested": {"key": "value"}}', {'nested': {'key': 'value'}}), + ('["item1", "item2"]', ['item1', 'item2']), + ('{}', {}), + ('[]', []), + ], +) +def test_load_json(input_data: str, expected_result: object) -> None: + """Test that load_json correctly loads and parses JSON data.""" + # Mock the open function to return our test data + with patch('pathlib.Path.open', mock_open(read_data=input_data)): + # Test with string path + result_str = load_json('dummy/path.json') + assert result_str == expected_result + + # Test with Path object + result_path = load_json(Path('dummy/path.json')) + assert result_path == expected_result + + +@pytest.mark.parametrize( + 'input_data_tuple', + [ + ({'key': 'value'},), + ({'nested': {'key': 'value'}},), + (['item1', 'item2'],), + ({},), + ([],), + ], +) +def test_save_as_indented_json(input_data_tuple: tuple[object, ...]) -> None: + """Test that save_as_indented_json correctly writes JSON data to a file.""" + input_data = input_data_tuple[0] + mock_file = mock_open() + + # Create a patch for both the open function and mkdir + with ( + patch('pathlib.Path.open', mock_file), + patch('pathlib.Path.mkdir') as mock_mkdir, + ): + # Test with string path + save_as_indented_json(input_data, 'dummy/path.json') + + # Verify mkdir was called with the expected parameters + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + + # Verify that the file was opened in write mode + mock_file.assert_called_once_with(mode='w', encoding='utf-8') + + # Get the handle to the mock file + handle = mock_file() + + # Verify that json.dump was called with the correct parameters + written_data = ''.join(call.args[0] for call in handle.write.call_args_list) + assert json.loads(written_data) == input_data + + +def test_save_as_indented_json_path_object() -> None: + """Test save_as_indented_json with a Path object.""" + mock_file = mock_open() + test_data = {'key': 'value'} + + with ( + patch('pathlib.Path.open', mock_file), + patch('pathlib.Path.mkdir') as mock_mkdir, + ): + # Test with Path object + save_as_indented_json(test_data, Path('dummy/path.json')) + + # Verify mkdir and open were called + mock_mkdir.assert_called_once() + mock_file.assert_called_once() + + # Verify content was written + handle = mock_file() + assert handle.write.called diff --git a/tests/project/common/utils/file/test_toml.py b/tests/project/common/utils/file/test_toml.py new file mode 100644 index 0000000..b0b25fc --- /dev/null +++ b/tests/project/common/utils/file/test_toml.py @@ -0,0 +1,88 @@ +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest + +from project.common.utils.file.toml import load_toml, save_as_toml + + +@pytest.mark.parametrize( + ('input_data', 'expected_result'), + [ + ('key = "value"', {'key': 'value'}), + ('[nested]\nkey = "value"', {'nested': {'key': 'value'}}), + ( + '[array]\nvalues = ["item1", "item2"]', + {'array': {'values': ['item1', 'item2']}}, + ), + ('', {}), + ], +) +def test_load_toml(input_data: str, expected_result: object) -> None: + """Test that load_toml correctly loads and parses TOML data.""" + # Mock the open function to return our test data + with patch('pathlib.Path.open', mock_open(read_data=input_data)): + # Test with string path + result_str = load_toml('dummy/path.toml') + assert result_str == expected_result + + # Test with Path object + result_path = load_toml(Path('dummy/path.toml')) + assert result_path == expected_result + + +@pytest.mark.parametrize( + 'input_data_tuple', + [ + ({'key': 'value'},), + ({'nested': {'key': 'value'}},), + ({'array': {'values': ['item1', 'item2']}},), + ({},), + ], +) +def test_save_as_toml(input_data_tuple: tuple[dict[str, object], ...]) -> None: + """Test that save_as_toml correctly writes TOML data to a file.""" + input_data = input_data_tuple[0] + mock_file = mock_open() + + # Create a patch for both the open function, mkdir, and toml.dump + with ( + patch('pathlib.Path.open', mock_file), + patch('pathlib.Path.mkdir') as mock_mkdir, + patch('toml.dump') as mock_dump, + ): + # Test with string path + save_as_toml(input_data, 'dummy/path.toml') + + # Verify mkdir was called with the expected parameters + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + + # Verify that the file was opened in write mode + mock_file.assert_called_once_with(mode='w', encoding='utf-8') + + # Get the handle to the mock file + handle = mock_file() + + # Verify toml.dump was called with the correct arguments + mock_dump.assert_called_once_with(input_data, handle) + + +def test_save_as_toml_path_object() -> None: + """Test save_as_toml with a Path object.""" + mock_file = mock_open() + test_data = {'key': 'value'} + + with ( + patch('pathlib.Path.open', mock_file), + patch('pathlib.Path.mkdir') as mock_mkdir, + ): + # Test with Path object + save_as_toml(test_data, Path('dummy/path.toml')) + + # Verify mkdir and open were called + mock_mkdir.assert_called_once() + mock_file.assert_called_once() + + # Verify content was written + handle = mock_file() + assert handle.write.called diff --git a/tests/project/common/utils/file/test_yaml.py b/tests/project/common/utils/file/test_yaml.py new file mode 100644 index 0000000..5ddbe8c --- /dev/null +++ b/tests/project/common/utils/file/test_yaml.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest + +from project.common.utils.file.yaml import load_yaml, save_as_indented_yaml + + +@pytest.mark.parametrize( + ('input_data', 'expected_result'), + [ + ('key: value', {'key': 'value'}), + ('nested:\n key: value', {'nested': {'key': 'value'}}), + ('- item1\n- item2', ['item1', 'item2']), + ('{}', {}), + ('[]', []), + ], +) +def test_load_yaml(input_data: str, expected_result: object) -> None: + """Test that load_yaml correctly loads and parses YAML data.""" + # Mock the open function to return our test data + with patch('pathlib.Path.open', mock_open(read_data=input_data)): + # Test with string path + result_str = load_yaml('dummy/path.yaml') + assert result_str == expected_result + + # Test with Path object + result_path = load_yaml(Path('dummy/path.yaml')) + assert result_path == expected_result + + +@pytest.mark.parametrize( + 'input_data_tuple', + [ + ({'key': 'value'},), + ({'nested': {'key': 'value'}},), + (['item1', 'item2'],), + ({},), + ([],), + ], +) +def test_save_as_indented_yaml(input_data_tuple: tuple[object, ...]) -> None: + """Test that save_as_indented_yaml correctly writes YAML data to a file.""" + input_data = input_data_tuple[0] + mock_file = mock_open() + + # Create a patch for both the open function and mkdir + with ( + patch('pathlib.Path.open', mock_file), + patch('pathlib.Path.mkdir') as mock_mkdir, + ): + # Test with string path + save_as_indented_yaml(input_data, 'dummy/path.yaml') + + # Verify mkdir was called with the expected parameters + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + + # Verify that the file was opened in write mode + mock_file.assert_called_once_with(mode='w', encoding='utf-8') + + # Get the handle to the mock file + handle = mock_file() + + # Verify content was written + assert handle.write.called + + # We can't easily verify the exact YAML output due to formatting differences, + # but we can check that it was called with something + written_data = ''.join(call.args[0] for call in handle.write.call_args_list) + assert written_data # Assert that something was written + + +def test_save_as_indented_yaml_path_object() -> None: + """Test save_as_indented_yaml with a Path object.""" + mock_file = mock_open() + test_data = {'key': 'value'} + + with ( + patch('pathlib.Path.open', mock_file), + patch('pathlib.Path.mkdir') as mock_mkdir, + ): + # Test with Path object + save_as_indented_yaml(test_data, Path('dummy/path.yaml')) + + # Verify mkdir and open were called + mock_mkdir.assert_called_once() + mock_file.assert_called_once() + + # Verify content was written + handle = mock_file() + assert handle.write.called diff --git a/tests/project/common/utils/test_async_utils.py b/tests/project/common/utils/test_async_utils.py new file mode 100644 index 0000000..7e6e325 --- /dev/null +++ b/tests/project/common/utils/test_async_utils.py @@ -0,0 +1,179 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from project.common.utils.async_utils import ( + AsyncResource, + async_to_sync_func, + run_async_function_with_semaphore, + sync_to_async_func, +) + + +def test_sync_to_async_func_preserves_metadata() -> None: + """Test that sync_to_async_func preserves the function name and docstring.""" + + def sample_func(x: int) -> int: + """Sample docstring.""" + return x * 2 + + async_func = sync_to_async_func(sample_func) + + assert async_func.__name__ == sample_func.__name__ + assert async_func.__doc__ == sample_func.__doc__ + + +@pytest.mark.parametrize( + ('input_value', 'expected'), + [ + (1, 2), + (5, 10), + (0, 0), + (-3, -6), + ], +) +@pytest.mark.asyncio +async def test_sync_to_async_func_results(input_value: int, expected: int) -> None: + """Test that sync_to_async_func correctly executes the wrapped function.""" + + def multiply_by_two(x: int) -> int: + return x * 2 + + async_multiply = sync_to_async_func(multiply_by_two) + result = await async_multiply(input_value) + + assert result == expected + + +def test_async_to_sync_func_preserves_metadata() -> None: + """Test that async_to_sync_func preserves the function name and docstring.""" + + async def sample_async_func(x: int) -> int: + """Sample async docstring.""" + return x * 2 + + sync_func = async_to_sync_func(sample_async_func) + + assert sync_func.__name__ == sample_async_func.__name__ + assert sync_func.__doc__ == sample_async_func.__doc__ + + +@pytest.mark.parametrize( + ('input_value', 'expected'), + [ + (1, 2), + (5, 10), + (0, 0), + (-3, -6), + ], +) +def test_async_to_sync_func_results(input_value: int, expected: int) -> None: + """Test that async_to_sync_func correctly executes the wrapped function.""" + + async def async_multiply_by_two(x: int) -> int: + await asyncio.sleep(0.01) # Small delay to simulate async work + return x * 2 + + sync_multiply = async_to_sync_func(async_multiply_by_two) + result = sync_multiply(input_value) + + assert result == expected + + +@pytest.mark.parametrize('use_semaphore_tuple', [(True,), (False,)]) +@pytest.mark.asyncio +async def test_run_async_function_with_semaphore(use_semaphore_tuple: tuple[bool, ...]) -> None: + """Test that run_async_function_with_semaphore correctly manages the semaphore.""" + use_semaphore = use_semaphore_tuple[0] + mock_async_func = AsyncMock(return_value='result') + mock_semaphore = AsyncMock() if use_semaphore else None + + # If a semaphore is provided, it should be used via async with + if use_semaphore: + assert mock_semaphore is not None # Type narrowing for mypy + mock_semaphore.__aenter__ = AsyncMock() + mock_semaphore.__aexit__ = AsyncMock() + + result = await run_async_function_with_semaphore(mock_async_func, mock_semaphore, 'arg1', 'arg2', kwarg1='kwarg1') + + # Verify the async function was called with the correct arguments + mock_async_func.assert_called_once_with('arg1', 'arg2', kwarg1='kwarg1') + + # If a semaphore was provided, verify it was acquired and released + if use_semaphore: + assert mock_semaphore is not None # Type narrowing for mypy + mock_semaphore.__aenter__.assert_called_once() + mock_semaphore.__aexit__.assert_called_once() + + assert result == 'result' + + +class TestAsyncResource: + class TestResource(AsyncResource): + """Test implementation of AsyncResource.""" + + def __init__(self, concurrency: int = 1) -> None: + super().__init__(concurrency=concurrency) + self.call_mock = AsyncMock() + + async def call(self, *args: object, **kwargs: object) -> str: + return await self.call_mock(*args, **kwargs) + + @pytest.mark.parametrize( + ('concurrency', 'num_tasks'), + [ + (1, 5), # Single concurrency should process one at a time + (3, 5), # Higher concurrency allows multiple tasks + ], + ) + @pytest.mark.asyncio + async def test_semaphore_limits_concurrency(self, concurrency: int, num_tasks: int) -> None: + """Test that AsyncResource correctly limits concurrency using its semaphore.""" + resource = self.TestResource(concurrency=concurrency) + resource.call_mock.return_value = 'test_result' + + # Track the number of concurrent executions + max_concurrent = 0 + current_concurrent = 0 + original_aenter = resource.semaphore.__aenter__ + + async def tracking_aenter(self: asyncio.Semaphore) -> asyncio.Semaphore: + nonlocal current_concurrent, max_concurrent + await original_aenter() + current_concurrent += 1 + max_concurrent = max(max_concurrent, current_concurrent) + return self + + original_aexit = resource.semaphore.__aexit__ + + async def tracking_aexit(_self: asyncio.Semaphore, *args: object) -> object: + nonlocal current_concurrent + current_concurrent -= 1 + return await original_aexit(*args) + + # Replace the enter and exit methods to track concurrency + resource.semaphore.__aenter__ = tracking_aenter.__get__(resource.semaphore) + resource.semaphore.__aexit__ = tracking_aexit.__get__(resource.semaphore) + + # Create and gather multiple tasks + tasks = [resource.task(f'arg{i}') for i in range(num_tasks)] + results = await asyncio.gather(*tasks) + + # Verify all tasks completed successfully + assert all(result == 'test_result' for result in results) + + # Verify the concurrency limit was respected + assert max_concurrent <= concurrency + + # Verify the call method was called with the correct arguments + assert resource.call_mock.call_count == num_tasks + for i in range(num_tasks): + resource.call_mock.assert_any_call(f'arg{i}') + + @pytest.mark.asyncio + async def test_abstract_call_method(self) -> None: + """Test that AsyncResource.call is abstract and must be implemented.""" + # We can't instantiate AsyncResource directly because it's abstract + with pytest.raises(TypeError, match=r'abstract method'): + AsyncResource() diff --git a/tests/project/common/utils/test_cli_utils.py b/tests/project/common/utils/test_cli_utils.py new file mode 100644 index 0000000..d7506cf --- /dev/null +++ b/tests/project/common/utils/test_cli_utils.py @@ -0,0 +1,76 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from project.common.utils.cli_utils import load_cli_config + + +@pytest.mark.parametrize( + ('config_file_exists', 'config_content', 'kwargs', 'expected'), + [ + # Case 1: Config file exists with content, no kwargs + (True, {'file_key': 'file_value'}, {}, {'file_key': 'file_value'}), + # Case 2: Config file exists with content, with kwargs + ( + True, + {'file_key': 'file_value', 'override_key': 'file_value'}, + {'override_key': 'cli_value', 'new_key': 'cli_value'}, + { + 'file_key': 'file_value', + 'override_key': 'cli_value', + 'new_key': 'cli_value', + }, + ), + # Case 3: No config file, with kwargs + (False, None, {'cli_key': 'cli_value'}, {'cli_key': 'cli_value'}), + # Case 4: No config file, no kwargs + (False, None, {}, {}), + ], +) +def test_load_cli_config( + config_file_exists: bool, + config_content: dict[str, object] | None, + kwargs: dict[str, object], + expected: dict[str, object], +) -> None: + mock_path = 'path/to/config.json' if config_file_exists else None + + # Mock the load_config function directly within the cli_utils module + with patch('project.common.utils.cli_utils.load_config') as mock_load_config: + mock_load_config.return_value = config_content + + # Call the function under test + result = load_cli_config(mock_path, **kwargs) + + # Verify the results + assert result == expected + + # Verify that load_config was called appropriately + if config_file_exists: + mock_load_config.assert_called_once_with(mock_path) + else: + mock_load_config.assert_not_called() + + +@pytest.mark.parametrize( + ('config_file_path', 'expected_path_type'), + [ + # Test with string path + ('path/to/config.json', str), + # Test with Path object + (Path('path/to/config.json'), Path), + ], +) +def test_load_cli_config_path_types(config_file_path: str | Path, expected_path_type: type[object]) -> None: + # Create a comprehensive mock to prevent file system access by mocking where the function is used + with patch('project.common.utils.cli_utils.load_config') as mock_load_config: + mock_load_config.return_value = {} + + # Ensure we don't actually try to open a file + load_cli_config(config_file_path) + + # Verify that load_config was called with the correct path type + mock_load_config.assert_called_once() + args, _ = mock_load_config.call_args + assert isinstance(args[0], expected_path_type) diff --git a/tests/project/common/utils/test_import_utils.py b/tests/project/common/utils/test_import_utils.py new file mode 100644 index 0000000..c12ac0f --- /dev/null +++ b/tests/project/common/utils/test_import_utils.py @@ -0,0 +1,80 @@ +import os +import sys +from collections.abc import Generator +from pathlib import Path +from unittest.mock import patch + +import pytest + +from project.common.utils.import_utils import get_imported_function_path, import_function + + +@pytest.fixture +def test_module_file(tmp_path: Path) -> Generator[str]: + """Create a temporary Python module for testing import functions.""" + module_dir = tmp_path / 'test_module' + module_dir.mkdir() + (module_dir / '__init__.py').write_text('') + + module_file = module_dir / 'test_func.py' + module_file.write_text( + 'def test_function():\n' + " return 'Hello from test_function'\n" + '\n' + 'def another_function():\n' + " return 'Hello from another_function'\n" + ) + + # Add the tmp_path to sys.path temporarily + sys.path.insert(0, str(tmp_path.parent)) + yield str(module_file) + + # Clean up + sys.path.remove(str(tmp_path.parent)) + + +@pytest.mark.parametrize( + ('function_name', 'expected_result'), + [ + ('test_function', 'Hello from test_function'), + ('another_function', 'Hello from another_function'), + ], +) +def test_import_function(test_module_file: str, function_name: str, expected_result: str) -> None: + # Handle the default case where function_name is None + if function_name is None: + # Mock the stem attribute to return "test_func" + with patch('pathlib.Path') as mock_path: + mock_path_instance = mock_path.return_value + mock_path_instance.resolve.return_value = Path(test_module_file) + mock_path_instance.stem = 'test_function' + + # Mock the current working directory + with patch('pathlib.Path.cwd') as mock_cwd: + mock_cwd.return_value = Path(test_module_file).parent.parent + + # Add mock for relative_to + mock_path_instance.relative_to.return_value = Path('test_module/test_func.py') + mock_path_instance.with_suffix.return_value = Path('test_module/test_func') + + function = import_function(test_module_file, function_name) + else: + # For normal cases where function_name is provided + with patch('pathlib.Path.cwd') as mock_cwd: + mock_cwd.return_value = Path(test_module_file).parent.parent + function = import_function(test_module_file, function_name) + + # Assert that the imported function returns the expected result + assert function() == expected_result + + +def test_get_imported_function_path() -> None: + # Create a dummy function for testing + def dummy_function() -> None: + pass + + # Get the file path of the dummy function + file_path = get_imported_function_path(dummy_function) + + # Assert that the returned path is the path of this test file + assert os.path.realpath(file_path) == os.path.realpath(__file__) diff --git a/tests/project/common/utils/test_regex_utils.py b/tests/project/common/utils/test_regex_utils.py new file mode 100644 index 0000000..c8f0858 --- /dev/null +++ b/tests/project/common/utils/test_regex_utils.py @@ -0,0 +1,51 @@ +import re + +import pytest + +from project.common.utils.regex_utils import concat, is_match_pattern, unmatched_group + + +@pytest.mark.parametrize( + ('input_regex', 'expected'), + [ + ('abc', r'(?:abc)'), + ('a|b', r'(?:a|b)'), + (r'\d+', r'(?:\d+)'), + ('', r'(?:)'), + ('a{2,3}', r'(?:a{2,3})'), + ], +) +def test_unmatched_group(input_regex: str, expected: str) -> None: + result = unmatched_group(input_regex) + assert result == expected + + +@pytest.mark.parametrize( + ('regexes', 'without_grouping', 'expected'), + [ + (['a', 'b'], False, r'(?:a|b)'), + (['a', 'b'], True, r'a|b'), + ([r'\d+', r'\w+'], False, r'(?:\d+|\w+)'), + ([], False, r'(?:)'), + (['abc'], False, r'(?:abc)'), + (['abc'], True, r'abc'), + ], +) +def test_concat(regexes: list[str], without_grouping: bool, expected: str) -> None: + result = concat(regexes, without_grouping) + assert result == expected + + +@pytest.mark.parametrize( + ('text', 'pattern', 'expected'), + [ + ('abc123', re.compile(r'\d+'), True), + ('abcdef', re.compile(r'\d+'), False), + ('', re.compile(r'.*'), True), + ('test@example.com', re.compile(r'@'), True), + ('Hello World', re.compile(r'[a-z]+'), True), + ], +) +def test_is_match_pattern(text: str, pattern: re.Pattern[str], expected: bool) -> None: + result = is_match_pattern(text, pattern) + assert result == expected diff --git a/tests/project/test_env.py b/tests/project/test_env.py index 68cb16e..9291aa4 100644 --- a/tests/project/test_env.py +++ b/tests/project/test_env.py @@ -1,5 +1,5 @@ from project.env import VERSION -def test_version(): +def test_version() -> None: assert VERSION == '0.1.0' diff --git a/tox.ini b/tox.ini index 5e590bb..c7267b8 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ allowlist_externals = ty skip_install = true commands = - ruff check ./ - ruff format --check ./ - mypy . - ;ty check . + ruff check src/ tests/ scripts/ + ruff format --check src/ tests/ scripts/ + mypy src/ tests/ scripts/ + ;ty check src/ tests/ scripts/ From f0be32c2afbbb298d18e6fbea5728946129c9e9d Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 02:18:16 +0900 Subject: [PATCH 5/7] refactor: update type hints for regex and JSON/YAML test functions --- src/project/common/regex.py | 2 +- tests/project/common/utils/file/test_json.py | 6 ++-- tests/project/common/utils/file/test_yaml.py | 6 ++-- .../project/common/utils/test_async_utils.py | 35 +++++++------------ 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/project/common/regex.py b/src/project/common/regex.py index 04e57d2..5b75706 100644 --- a/src/project/common/regex.py +++ b/src/project/common/regex.py @@ -4,7 +4,7 @@ from project.common.utils.regex_utils import concat, unmatched_group # time regex -TIME_PATTRN: Final[re.Pattern] = re.compile(r'\d+:\d+') +TIME_PATTRN: Final[re.Pattern[str]] = re.compile(r'\d+:\d+') # email regex diff --git a/tests/project/common/utils/file/test_json.py b/tests/project/common/utils/file/test_json.py index 46880ff..dff314f 100644 --- a/tests/project/common/utils/file/test_json.py +++ b/tests/project/common/utils/file/test_json.py @@ -4,7 +4,7 @@ import pytest -from project.common.utils.file.json import load_json, save_as_indented_json +from project.common.utils.file.json import JsonValue, load_json, save_as_indented_json @pytest.mark.parametrize( @@ -17,7 +17,7 @@ ('[]', []), ], ) -def test_load_json(input_data: str, expected_result: object) -> None: +def test_load_json(input_data: str, expected_result: JsonValue) -> None: """Test that load_json correctly loads and parses JSON data.""" # Mock the open function to return our test data with patch('pathlib.Path.open', mock_open(read_data=input_data)): @@ -40,7 +40,7 @@ def test_load_json(input_data: str, expected_result: object) -> None: ([],), ], ) -def test_save_as_indented_json(input_data_tuple: tuple[object, ...]) -> None: +def test_save_as_indented_json(input_data_tuple: tuple[JsonValue, ...]) -> None: """Test that save_as_indented_json correctly writes JSON data to a file.""" input_data = input_data_tuple[0] mock_file = mock_open() diff --git a/tests/project/common/utils/file/test_yaml.py b/tests/project/common/utils/file/test_yaml.py index 5ddbe8c..742ddef 100644 --- a/tests/project/common/utils/file/test_yaml.py +++ b/tests/project/common/utils/file/test_yaml.py @@ -3,7 +3,7 @@ import pytest -from project.common.utils.file.yaml import load_yaml, save_as_indented_yaml +from project.common.utils.file.yaml import YamlValue, load_yaml, save_as_indented_yaml @pytest.mark.parametrize( @@ -16,7 +16,7 @@ ('[]', []), ], ) -def test_load_yaml(input_data: str, expected_result: object) -> None: +def test_load_yaml(input_data: str, expected_result: YamlValue) -> None: """Test that load_yaml correctly loads and parses YAML data.""" # Mock the open function to return our test data with patch('pathlib.Path.open', mock_open(read_data=input_data)): @@ -39,7 +39,7 @@ def test_load_yaml(input_data: str, expected_result: object) -> None: ([],), ], ) -def test_save_as_indented_yaml(input_data_tuple: tuple[object, ...]) -> None: +def test_save_as_indented_yaml(input_data_tuple: tuple[YamlValue, ...]) -> None: """Test that save_as_indented_yaml correctly writes YAML data to a file.""" input_data = input_data_tuple[0] mock_file = mock_open() diff --git a/tests/project/common/utils/test_async_utils.py b/tests/project/common/utils/test_async_utils.py index 7e6e325..4270cbc 100644 --- a/tests/project/common/utils/test_async_utils.py +++ b/tests/project/common/utils/test_async_utils.py @@ -1,4 +1,5 @@ import asyncio +import inspect from unittest.mock import AsyncMock import pytest @@ -87,11 +88,11 @@ async def test_run_async_function_with_semaphore(use_semaphore_tuple: tuple[bool """Test that run_async_function_with_semaphore correctly manages the semaphore.""" use_semaphore = use_semaphore_tuple[0] mock_async_func = AsyncMock(return_value='result') - mock_semaphore = AsyncMock() if use_semaphore else None + mock_semaphore: AsyncMock | None = AsyncMock() if use_semaphore else None # If a semaphore is provided, it should be used via async with if use_semaphore: - assert mock_semaphore is not None # Type narrowing for mypy + assert mock_semaphore is not None mock_semaphore.__aenter__ = AsyncMock() mock_semaphore.__aexit__ = AsyncMock() @@ -102,7 +103,7 @@ async def test_run_async_function_with_semaphore(use_semaphore_tuple: tuple[bool # If a semaphore was provided, verify it was acquired and released if use_semaphore: - assert mock_semaphore is not None # Type narrowing for mypy + assert mock_semaphore is not None mock_semaphore.__aenter__.assert_called_once() mock_semaphore.__aexit__.assert_called_once() @@ -131,30 +132,20 @@ async def call(self, *args: object, **kwargs: object) -> str: async def test_semaphore_limits_concurrency(self, concurrency: int, num_tasks: int) -> None: """Test that AsyncResource correctly limits concurrency using its semaphore.""" resource = self.TestResource(concurrency=concurrency) - resource.call_mock.return_value = 'test_result' - - # Track the number of concurrent executions max_concurrent = 0 current_concurrent = 0 - original_aenter = resource.semaphore.__aenter__ - async def tracking_aenter(self: asyncio.Semaphore) -> asyncio.Semaphore: + async def tracked_call(*_args: object, **_kwargs: object) -> str: nonlocal current_concurrent, max_concurrent - await original_aenter() current_concurrent += 1 max_concurrent = max(max_concurrent, current_concurrent) - return self - - original_aexit = resource.semaphore.__aexit__ - - async def tracking_aexit(_self: asyncio.Semaphore, *args: object) -> object: - nonlocal current_concurrent - current_concurrent -= 1 - return await original_aexit(*args) + try: + await asyncio.sleep(0.01) + return 'test_result' + finally: + current_concurrent -= 1 - # Replace the enter and exit methods to track concurrency - resource.semaphore.__aenter__ = tracking_aenter.__get__(resource.semaphore) - resource.semaphore.__aexit__ = tracking_aexit.__get__(resource.semaphore) + resource.call_mock.side_effect = tracked_call # Create and gather multiple tasks tasks = [resource.task(f'arg{i}') for i in range(num_tasks)] @@ -174,6 +165,4 @@ async def tracking_aexit(_self: asyncio.Semaphore, *args: object) -> object: @pytest.mark.asyncio async def test_abstract_call_method(self) -> None: """Test that AsyncResource.call is abstract and must be implemented.""" - # We can't instantiate AsyncResource directly because it's abstract - with pytest.raises(TypeError, match=r'abstract method'): - AsyncResource() + assert inspect.isabstract(AsyncResource) From 13bb6397ca045adef8464a81e9de46a5a2013c0c Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 02:19:18 +0900 Subject: [PATCH 6/7] feat: add types-requests to development dependencies in pyproject.toml and uv.lock --- pyproject.toml | 1 + uv.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 257cc79..22bcd58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,4 +149,5 @@ dev = [ "pre-commit>=4.3.0", "types-pyyaml>=6.0.12.20250402", "types-toml>=0.10.8.20240310", + "types-requests>=2.32.0.20241016", ] diff --git a/uv.lock b/uv.lock index 24835ab..94249a4 100644 --- a/uv.lock +++ b/uv.lock @@ -1997,6 +1997,7 @@ dev = [ { name = "tox" }, { name = "ty" }, { name = "types-pyyaml" }, + { name = "types-requests" }, { name = "types-toml" }, ] @@ -2040,6 +2041,7 @@ dev = [ { name = "tox", specifier = ">=4.30.0" }, { name = "ty", specifier = ">=0.0.1a26" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250402" }, + { name = "types-requests", specifier = ">=2.32.0.20241016" }, { name = "types-toml", specifier = ">=0.10.8.20240310" }, ] @@ -3029,6 +3031,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, ] +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, +] + [[package]] name = "types-toml" version = "0.10.8.20240310" From e105c4ad563deccfa4ac5b6253f1bc5a088aed8a Mon Sep 17 00:00:00 2001 From: iamtatsuki05 Date: Tue, 18 Nov 2025 03:20:26 +0900 Subject: [PATCH 7/7] feat: add new dependencies returns, cachetools, and more-itertools in pyproject.toml and uv.lock --- pyproject.toml | 3 +++ uv.lock | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 22bcd58..3441def 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "aiohttp>=3.9.5", "tenacity>=9.1.2", "toml>=0.10.2", + "returns>=0.26.0", + "cachetools>=6.2.1", + "more-itertools>=10.8.0", ] [tool.hatch.build.targets.wheel] diff --git a/uv.lock b/uv.lock index 94249a4..d92dcca 100644 --- a/uv.lock +++ b/uv.lock @@ -1409,6 +1409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "msgspec-m" version = "0.19.2" @@ -1961,11 +1970,13 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "beautifulsoup4" }, + { name = "cachetools" }, { name = "fastapi" }, { name = "fire" }, { name = "japanize-matplotlib" }, { name = "matplotlib" }, { name = "matplotlib-fontja" }, + { name = "more-itertools" }, { name = "numpy" }, { name = "openpyxl" }, { name = "pandas" }, @@ -1973,6 +1984,7 @@ dependencies = [ { name = "polars" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "returns" }, { name = "scikit-learn" }, { name = "seaborn" }, { name = "selenium" }, @@ -2005,11 +2017,13 @@ dev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.9.5" }, { name = "beautifulsoup4", specifier = ">=4.14.0" }, + { name = "cachetools", specifier = ">=6.2.1" }, { name = "fastapi", specifier = ">=0.121.0" }, { name = "fire", specifier = ">=0.7.1" }, { name = "japanize-matplotlib", specifier = ">=1.1.3" }, { name = "matplotlib", specifier = ">=3.10.7" }, { name = "matplotlib-fontja", specifier = ">=1.1.0" }, + { name = "more-itertools", specifier = ">=10.8.0" }, { name = "numpy", specifier = ">=2.3.4" }, { name = "openpyxl", specifier = ">=3.1.2" }, { name = "pandas", specifier = ">=2.3.3" }, @@ -2017,6 +2031,7 @@ requires-dist = [ { name = "polars", specifier = ">=1.35.2" }, { name = "pydantic", specifier = ">=2.12.4" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "returns", specifier = ">=0.26.0" }, { name = "scikit-learn", specifier = ">=1.7.2" }, { name = "seaborn", specifier = ">=0.13.2" }, { name = "selenium", specifier = ">=4.38.0" }, @@ -2519,6 +2534,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "returns" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c2/6dda7ef39464568152e35c766a8b49ab1cdb1b03a5891441a7c2fa40dc61/returns-0.26.0.tar.gz", hash = "sha256:180320e0f6e9ea9845330ccfc020f542330f05b7250941d9b9b7c00203fcc3da", size = 105300, upload-time = "2025-07-24T13:11:21.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/4d/a7545bf6c62b0dbe5795f22ea9e88cc070fdced5c34663ebc5bed2f610c0/returns-0.26.0-py3-none-any.whl", hash = "sha256:7cae94c730d6c56ffd9d0f583f7a2c0b32cfe17d141837150c8e6cff3eb30d71", size = 160515, upload-time = "2025-07-24T13:11:20.041Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4"