Skip to content

Commit 247e74a

Browse files
authored
Merge pull request #14 from iamtatsuki05/develop
Sync: Develop to Main
2 parents 1ccde46 + cf31357 commit 247e74a

24 files changed

Lines changed: 984 additions & 105 deletions

.pre-commit-config.yaml

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
# See https://pre-commit.com for more information
2+
# See https://pre-commit.com/hooks.html for more hooks
13
repos:
2-
- repo: https://github.com/psf/black
3-
rev: 22.12.0
4+
- repo: https://github.com/pre-commit/pre-commit-hooks
5+
rev: v3.2.0
46
hooks:
5-
- id: black
6-
types: [python]
7-
language_version: python3.10.11
8-
- repo: https://github.com/PyCQA/isort
9-
rev: 5.12.0
7+
- id: trailing-whitespace
8+
- id: end-of-file-fixer
9+
- id: check-yaml
10+
- id: check-added-large-files
11+
- repo: https://github.com/astral-sh/ruff-pre-commit
12+
rev: v0.14.4
1013
hooks:
11-
- id: isort
14+
- id: ruff
15+
args: [ --fix ]
16+
types: [python]
17+
- id: ruff-format
1218
types: [python]
13-
language_version: python3.10.11

pyproject.toml

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,47 +27,108 @@ dependencies = [
2727
"aiohttp>=3.9.5",
2828
"tenacity>=9.1.2",
2929
"toml>=0.10.2",
30+
"returns>=0.26.0",
31+
"cachetools>=6.2.1",
32+
"more-itertools>=10.8.0",
3033
]
3134

3235
[tool.hatch.build.targets.wheel]
3336
packages = ["src/project", "src"]
3437

38+
[tool.uv]
39+
default-groups = ["dev"]
40+
3541
[tool.ruff]
36-
line-length = 119
3742
target-version = "py313"
38-
exclude = [".git", ".venv", "__pycache__", "data", "dist", "misc", "notebooks", "prof", "tmp", "workspacea", ".tox"]
39-
40-
[tool.ruff.format]
41-
quote-style = "single"
42-
indent-style = "space"
43-
skip-magic-trailing-comma = false
44-
line-ending = "auto"
43+
line-length = 119
44+
indent-width = 4
45+
exclude = [
46+
".bzr",
47+
".direnv",
48+
".eggs",
49+
".git",
50+
".git-rewrite",
51+
".hg",
52+
".ipynb_checkpoints",
53+
".mypy_cache",
54+
".nox",
55+
".pants.d",
56+
".pyenv",
57+
".pytest_cache",
58+
".pytype",
59+
".ruff_cache",
60+
".svn",
61+
".tox",
62+
".venv",
63+
".vscode",
64+
"__pypackages__",
65+
"_build",
66+
"buck-out",
67+
"build",
68+
"dist",
69+
"node_modules",
70+
"site-packages",
71+
"venv",
72+
]
4573

4674
[tool.ruff.lint]
47-
extend-select = [
48-
"I", # isort
75+
select = ["ALL"]
76+
ignore = [
77+
"D100",
78+
"D101",
79+
"D102",
80+
"D103",
81+
"D104",
82+
"D105",
83+
"D106",
84+
"D107",
85+
"D203",
86+
"D213",
87+
"G004",
88+
"Q000",
89+
"Q003",
90+
"EM101",
91+
"EM102",
92+
"COM812",
93+
"FBT001",
94+
"FBT002",
95+
"TRY003",
96+
"INP001",
97+
]
98+
fixable = ["ALL"]
99+
unfixable = []
100+
101+
[tool.ruff.lint.per-file-ignores]
102+
"tests/**/*.py" = [
103+
"S101",
104+
"PLR2004",
49105
]
50-
# ignores
51-
ignore = ["E501"]
52106

53107
[tool.ruff.lint.isort]
54-
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
108+
section-order = [
109+
"future",
110+
"standard-library",
111+
"third-party",
112+
"first-party",
113+
"local-folder",
114+
]
55115
split-on-trailing-comma = true
56116

57-
[tool.ruff.lint.pyupgrade]
58-
# Python3.8互換のための設定
59-
keep-runtime-typing = true
117+
[tool.ruff.format]
118+
quote-style = "single"
119+
indent-style = "space"
120+
skip-magic-trailing-comma = false
121+
line-ending = "auto"
60122

61123
[tool.mypy]
62124
python_version="3.13"
63125
files = "src"
64126
ignore_missing_imports = true
65127
disallow_untyped_defs = true
66128
no_implicit_optional = true
67-
allow_redefinition = true
129+
allow_redefinition = false
68130
show_error_codes = true
69131
pretty = true
70-
allow_untyped_globals = true
71132

72133
[tool.pytest.ini_options]
73134
filterwarnings = ["ignore::DeprecationWarning",]
@@ -91,4 +152,5 @@ dev = [
91152
"pre-commit>=4.3.0",
92153
"types-pyyaml>=6.0.12.20250402",
93154
"types-toml>=0.10.8.20240310",
155+
"types-requests>=2.32.0.20241016",
94156
]

scripts/main.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,16 @@
1+
import logging
2+
3+
from project.env import PACKAGE_DIR
4+
5+
logger = logging.getLogger(__name__)
6+
7+
8+
def main() -> None:
9+
"""Sample entry point."""
10+
logging.basicConfig(level=logging.INFO)
11+
logger.info('Hello, World!')
12+
logger.info('PACKAGE_DIR=%s', PACKAGE_DIR)
13+
14+
115
if __name__ == '__main__':
2-
print('Hello, World!')
16+
main()

src/project/common/regex.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import re
2+
from typing import Final
3+
4+
from project.common.utils.regex_utils import concat, unmatched_group
5+
6+
# time regex
7+
TIME_PATTRN: Final[re.Pattern[str]] = re.compile(r'\d+:\d+')
8+
9+
10+
# email regex
11+
LOCAL_PART_CHARS = r'[\w\-._]'
12+
DOMAIN_CHARS = r'[\w\-._]'
13+
TLD_CHARS = r'[A-Za-z]'
14+
15+
local_part = concat([LOCAL_PART_CHARS], without_grouping=True) + r'+'
16+
domain = concat([DOMAIN_CHARS], without_grouping=True) + r'+'
17+
tld = concat([TLD_CHARS], without_grouping=True) + r'+'
18+
19+
EMAIL_REGEX = local_part + r'@' + domain + r'\.' + tld
20+
EMAIL_PATTERN = re.compile(EMAIL_REGEX)
21+
22+
23+
# url regex
24+
SCHEME = r'https?'
25+
CHARS = r'[\w!?/+\-_~;.,*&@#$%()\[\]]'
26+
27+
url_chars = concat([CHARS], without_grouping=True) + r'+'
28+
29+
HTTP_URL_REGEX = SCHEME + r'://' + url_chars
30+
31+
DATA_SCHEME = r'data:'
32+
MEDIATYPE = r'[\w/+.-]+'
33+
BASE64 = r'base64'
34+
DATA = r'[\w+/=]+'
35+
36+
mediatype_part = unmatched_group(MEDIATYPE) + r'?'
37+
base64_part = unmatched_group(BASE64) + r'?'
38+
data_part = unmatched_group(DATA)
39+
40+
DATA_URL_REGEX = DATA_SCHEME + mediatype_part + r'(?:;' + base64_part + r')?,' + data_part
41+
42+
URL_REGEX = concat([HTTP_URL_REGEX, DATA_URL_REGEX])
43+
44+
HTTP_URL_PATTERN = re.compile(HTTP_URL_REGEX)
45+
DATA_URL_PATTERN = re.compile(DATA_URL_REGEX)
46+
URL_PATTERN = re.compile(URL_REGEX)
Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,55 @@
11
import asyncio
2-
from typing import Any, Callable
2+
from abc import ABC, abstractmethod
3+
from collections.abc import Awaitable, Callable, Coroutine
4+
from typing import Any
35

46

5-
def sync_to_async_func(sync_func: Callable) -> Callable:
6-
"""
7-
同期関数を非同期関数として使えるように変換する
8-
"""
7+
def sync_to_async_func[R](sync_func: Callable[..., R]) -> Callable[..., Awaitable[R]]:
8+
"""Convert a synchronous callable into an asynchronous callable."""
99

10-
async def wrapper(*args: Any, **kwargs: Any) -> Any:
10+
async def wrapper(*args: object, **kwargs: object) -> R:
1111
return await asyncio.to_thread(sync_func, *args, **kwargs)
1212

1313
wrapper.__name__ = sync_func.__name__
1414
wrapper.__doc__ = sync_func.__doc__
1515
return wrapper
1616

1717

18-
def async_to_sync_func(async_func: Callable) -> Callable:
19-
"""
20-
非同期関数を同期関数として使えるように変換する
21-
"""
18+
def async_to_sync_func[R](async_func: Callable[..., Coroutine[Any, Any, R]]) -> Callable[..., R]:
19+
"""Convert an asynchronous callable into a synchronous callable."""
2220

23-
def wrapper(*args: Any, **kwargs: Any) -> Any:
21+
def wrapper(*args: object, **kwargs: object) -> R:
2422
return asyncio.run(async_func(*args, **kwargs))
2523

2624
wrapper.__name__ = async_func.__name__
2725
wrapper.__doc__ = async_func.__doc__
2826
return wrapper
2927

3028

31-
async def run_async_function_with_semaphore(
32-
async_func: Callable, concurrency_sema: asyncio.Semaphore | None, *args: Any, **kwargs: Any
33-
) -> Any:
34-
"""
35-
指定した関数 func を、セマフォで同時実行数を制限して呼び出す関数。
36-
concurrency_sema が None の場合は制限しない。
37-
"""
29+
async def run_async_function_with_semaphore[R](
30+
async_func: Callable[..., Awaitable[R]],
31+
concurrency_sema: asyncio.Semaphore | None,
32+
*args: object,
33+
**kwargs: object,
34+
) -> R:
35+
"""Execute async_func with an optional semaphore limiting concurrency."""
3836
if concurrency_sema is not None:
3937
async with concurrency_sema:
4038
return await async_func(*args, **kwargs)
41-
else:
42-
return await async_func(*args, **kwargs)
39+
return await async_func(*args, **kwargs)
40+
41+
42+
class AsyncResource[R](ABC):
43+
"""Base class for async resources protected by a semaphore."""
44+
45+
def __init__(self, concurrency: int = 1) -> None:
46+
self.semaphore = asyncio.Semaphore(concurrency)
47+
48+
async def task(self, *args: object, **kwargs: object) -> R:
49+
async with self.semaphore:
50+
return await self.call(*args, **kwargs)
51+
52+
@abstractmethod
53+
async def call(self, *args: object, **kwargs: object) -> R:
54+
"""Execute the concrete asynchronous operation."""
55+
raise NotImplementedError

src/project/common/utils/cli_utils.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,58 @@
22
from pathlib import Path
33
from typing import Any
44

5+
from pydantic import BaseModel
6+
57
from project.common.utils.file.config import load_config
68

79
logging.basicConfig(level=logging.INFO)
810
logger = logging.getLogger(__name__)
911

1012

11-
def load_cli_config(config_file_path: str | Path | None = None, **kwargs: Any) -> dict[str, Any]:
12-
"""
13-
Load configuration from a file and merge it with runtime arguments.
14-
"""
13+
def load_cli_config(config_file_path: str | Path | None = None, **kwargs: object) -> dict[str, Any]:
14+
"""Load configuration from a file and merge it with runtime arguments."""
1515
if config_file_path:
16-
logger.info(f'Loading configuration from {config_file_path}')
16+
logger.info('Loading configuration from %s', config_file_path)
1717
merged = load_config(config_file_path)
1818
merged.update(kwargs)
19-
logger.info(f'Merged config from file with overrides: {kwargs.keys() if kwargs else {}}')
19+
logger.info('Merged config with overrides: %s', list(kwargs) if kwargs else [])
2020
else:
21-
merged = kwargs
21+
merged = dict(kwargs)
2222
logger.info('No config file provided; using runtime arguments only.')
2323
return merged
24+
25+
26+
def load_and_parse_config[T: BaseModel](
27+
config_class: type[T],
28+
config_file_path: str | Path | None = None,
29+
**kwargs: object,
30+
) -> T:
31+
"""Load configuration from file, merge with kwargs, and parse into Pydantic model.
32+
33+
This function provides type-safe configuration loading by:
34+
1. Loading config from file (if provided)
35+
2. Merging with CLI overrides
36+
3. Validating and parsing into the specified Pydantic model
37+
38+
Args:
39+
config_class: Pydantic BaseModel subclass to parse into
40+
config_file_path: Path to config file (JSON/YAML/TOML)
41+
**kwargs: CLI overrides to merge with file config
42+
43+
Returns:
44+
Validated instance of config_class
45+
46+
Raises:
47+
ValidationError: If configuration is invalid
48+
49+
Example:
50+
>>> from pydantic import BaseModel, Field
51+
>>> class MyConfig(BaseModel):
52+
... name: str = Field(...)
53+
... value: int = Field(default=0)
54+
>>> cfg = load_and_parse_config(MyConfig, 'config.json', value=42)
55+
>>> assert isinstance(cfg, MyConfig)
56+
57+
"""
58+
raw_config = load_cli_config(config_file_path, **kwargs)
59+
return config_class(**raw_config)

src/project/common/utils/file/config.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
from pathlib import Path
2-
from typing import Any, cast
2+
from typing import Any
33

44
from project.common.utils.file.json import load_json
55
from project.common.utils.file.toml import load_toml
66
from project.common.utils.file.yaml import load_yaml
77

88

99
def load_config(path: str | Path) -> dict[str, Any]:
10-
"""
11-
Load configuration from a file (JSON, YAML, or TOML).
12-
"""
10+
"""Load configuration from a file (JSON, YAML, or TOML)."""
1311
ext = Path(path).suffix.lower()
1412

1513
if ext == '.json':
@@ -24,4 +22,4 @@ def load_config(path: str | Path) -> dict[str, Any]:
2422
if not isinstance(data, dict):
2523
raise TypeError(f'Config file {path!r} did not return a dict, got {type(data).__name__}')
2624

27-
return cast(dict[str, Any], data)
25+
return data

0 commit comments

Comments
 (0)