Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
98 changes: 80 additions & 18 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,47 +27,108 @@ 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]
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"
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",]
Expand All @@ -91,4 +152,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",
]
16 changes: 15 additions & 1 deletion scripts/main.py
Original file line number Diff line number Diff line change
@@ -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()
46 changes: 46 additions & 0 deletions src/project/common/regex.py
Original file line number Diff line number Diff line change
@@ -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[str]] = 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)
53 changes: 33 additions & 20 deletions src/project/common/utils/async_utils.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,55 @@
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__
wrapper.__doc__ = sync_func.__doc__
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__
wrapper.__doc__ = async_func.__doc__
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
50 changes: 43 additions & 7 deletions src/project/common/utils/cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 3 additions & 5 deletions src/project/common/utils/file/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
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
from project.common.utils.file.yaml import load_yaml


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':
Expand All @@ -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
Loading