diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index e5903cf..51cbddd 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -1,10 +1,4 @@ -Your goal is to write a commit message and open a pull request. +Your goal is to write a commit message. 1) Write a clear and concise commit message that follows conventional commit format (if applicable). It should explain what was changed and why. - -2) Create a pull request with: -* A descriptive title -* A summary of changes -* Any relevant context or background -* Linked issues (if any) -* Checklist of tasks done (tests, docs, etc.) \ No newline at end of file +2) Take a look to the branch name to understand context of the issue/feature \ No newline at end of file diff --git a/.claude/commands/commit_and_pr.md b/.claude/commands/commit_and_pr.md new file mode 100644 index 0000000..e5903cf --- /dev/null +++ b/.claude/commands/commit_and_pr.md @@ -0,0 +1,10 @@ +Your goal is to write a commit message and open a pull request. + +1) Write a clear and concise commit message that follows conventional commit format (if applicable). It should explain what was changed and why. + +2) Create a pull request with: +* A descriptive title +* A summary of changes +* Any relevant context or background +* Linked issues (if any) +* Checklist of tasks done (tests, docs, etc.) \ No newline at end of file diff --git a/.claude/commands/create_pr.md b/.claude/commands/create_pr.md new file mode 100644 index 0000000..eaa0f63 --- /dev/null +++ b/.claude/commands/create_pr.md @@ -0,0 +1,8 @@ +Your goal is to analyze this branch and open a pull request. + +1Create a pull request with: +* A descriptive title +* A summary of changes +* Any relevant context or background +* Linked issues (if any) +* Checklist of tasks done (tests, docs, etc.) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6e95b12..ed1a5dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,5 +17,6 @@ dependencies = [ "pyyaml>=6.0.2", "ruff>=0.12.5", "sqlalchemy>=2.0.41", + "ty>=0.0.1a16", "uvicorn>=0.35.0", ] diff --git a/src/application/auth/tg.py b/src/application/auth/tg.py index 0c75cd8..35f8a6e 100644 --- a/src/application/auth/tg.py +++ b/src/application/auth/tg.py @@ -2,7 +2,7 @@ from src.application.common.interactor import Interactor from src.application.common.transaction import TransactionManager -from src.application.interfaces.auth import AuthService, InitDataDTO +from src.application.interfaces.auth import AuthService from src.domain.user import ( CreateUserDTO, UserRepository, @@ -30,11 +30,10 @@ def __init__( self.transaction_manager = transaction_manager self.auth_service = auth_service - async def __call__(self, data: AuthTgInputDTO) -> AuthTgOutputDTO: parsed_data = self.auth_service.validate_init_data(data.init_data) - user = await self.user_repository.get_user(parsed_data.user_id, by='id') + user = await self.user_repository.get_user(parsed_data.user_id, by="id") if user is None: await self.user_repository.create_user( CreateUserDTO( diff --git a/src/application/interfaces/auth.py b/src/application/interfaces/auth.py index e341626..b959eaa 100644 --- a/src/application/interfaces/auth.py +++ b/src/application/interfaces/auth.py @@ -12,7 +12,7 @@ class InitDataDTO: is_premium: bool start_param: str | None photo_url: str - ui_language_code: str + ui_language_code: str | None class AuthService(Protocol): diff --git a/src/domain/user/__init__.py b/src/domain/user/__init__.py index 8ab80d8..fac0da7 100644 --- a/src/domain/user/__init__.py +++ b/src/domain/user/__init__.py @@ -1,2 +1,9 @@ from .entity import User -from .repository import UserRepository, CreateUserDTO \ No newline at end of file +from .repository import UserRepository, CreateUserDTO + + +__all__ = [ + "User", + "CreateUserDTO", + "UserRepository", +] diff --git a/src/domain/user/repository.py b/src/domain/user/repository.py index f1f5d54..2c2f0c6 100644 --- a/src/domain/user/repository.py +++ b/src/domain/user/repository.py @@ -11,6 +11,7 @@ class CreateUserDTO(TypedDict): is_premium: bool photo_url: str + class UpdateUserDTO(TypedDict): username: str | None first_name: str @@ -28,7 +29,9 @@ async def get_user(self, identifier: str) -> User: ... async def get_user(self, identifier: int, by: str = Literal["id"]) -> User: ... @overload - async def get_user(self, identifier: str, by: str = Literal["username"]) -> User: ... + async def get_user( + self, identifier: str, by: str = Literal["username"] + ) -> User: ... @abstractmethod async def get_user( diff --git a/src/infrastructure/auth.py b/src/infrastructure/auth.py index 19f18e5..357e555 100644 --- a/src/infrastructure/auth.py +++ b/src/infrastructure/auth.py @@ -15,8 +15,10 @@ def __init__(self, config: Config) -> None: def validate_init_data(self, init_data: str) -> InitDataDTO: # todo - cover with tests try: - parsed_data = safe_parse_webapp_init_data(self.config.telegram.bot_token, init_data) - except ValueError as e: + parsed_data = safe_parse_webapp_init_data( + self.config.telegram.bot_token, init_data + ) + except ValueError: error_msg = f"Invalid init data '{init_data}'" raise ValidationError(message=error_msg) @@ -32,14 +34,17 @@ def validate_init_data(self, init_data: str) -> InitDataDTO: is_premium=parsed_data.user.is_premium or False, start_param=parsed_data.start_param, photo_url=parsed_data.user.photo_url, - ui_language_code=parsed_data.user.language_code, + ui_language_code=parsed_data.user.language_code + if parsed_data.user.language_code + else None, ) def create_access_token(self, user_id: int) -> str: # todo - cover with tests to_encode = { "sub": str(user_id), - "exp": datetime.now(UTC) + timedelta(minutes=self.config.auth.access_token_expire_minutes), + "exp": datetime.now(UTC) + + timedelta(minutes=self.config.auth.access_token_expire_minutes), } encoded_jwt = encode( to_encode, diff --git a/src/infrastructure/config.py b/src/infrastructure/config.py index a27f750..19b5ead 100644 --- a/src/infrastructure/config.py +++ b/src/infrastructure/config.py @@ -29,6 +29,7 @@ class AuthConfig(BaseModel): algorithm: str access_token_expire_minutes: int + class TelegramConfig(BaseModel): bot_token: str diff --git a/src/infrastructure/db/models/user.py b/src/infrastructure/db/models/user.py index 488f186..9f2f85a 100644 --- a/src/infrastructure/db/models/user.py +++ b/src/infrastructure/db/models/user.py @@ -1,6 +1,5 @@ from typing import Optional -from sqlalchemy import Column from sqlalchemy.orm import Mapped, mapped_column from src.domain.user.entity import User @@ -19,7 +18,7 @@ class UserModel(BaseORMModel): username: Mapped[Optional[Username]] = mapped_column( UsernameType, nullable=True, unique=False ) - bio: Mapped[Optional[Bio]] = Column(BioType, nullable=True) + bio: Mapped[Bio | None] = mapped_column(BioType, nullable=True) def to_domain(self) -> User: return User( diff --git a/src/infrastructure/db/repos/__init__.py b/src/infrastructure/db/repos/__init__.py index 2ad3043..6df7de9 100644 --- a/src/infrastructure/db/repos/__init__.py +++ b/src/infrastructure/db/repos/__init__.py @@ -2,5 +2,5 @@ __all__ = [ - 'UserRepositoryImpl', + "UserRepositoryImpl", ] diff --git a/src/infrastructure/db/repos/user.py b/src/infrastructure/db/repos/user.py index 848639b..0659dda 100644 --- a/src/infrastructure/db/repos/user.py +++ b/src/infrastructure/db/repos/user.py @@ -25,24 +25,32 @@ async def get_user( return user_model.to_domain() if user_model else None async def create_user(self, user: CreateUserDTO) -> User: - stmt = insert(UserModel).values( - id=user["id"], - username=user["username"], - first_name=user["first_name"], - last_name=user["last_name"], - ).returning(UserModel) + stmt = ( + insert(UserModel) + .values( + id=user["id"], + username=user["username"], + first_name=user["first_name"], + last_name=user["last_name"], + ) + .returning(UserModel) + ) result = await self._session.execute(stmt) orm_model = result.scalar_one() return orm_model.to_domain() async def update_user(self, user_id: int, **fields: Unpack[UpdateUserDTO]) -> User: - stmt = update(UserModel).values( - id=user_id, - username=fields["username"], - first_name=fields["first_name"], - last_name=fields["last_name"], - ).returning(UserModel) + stmt = ( + update(UserModel) + .values( + id=user_id, + username=fields["username"], + first_name=fields["first_name"], + last_name=fields["last_name"], + ) + .returning(UserModel) + ) result = await self._session.execute(stmt) orm_model = result.scalar_one() return orm_model.to_domain() diff --git a/src/presentation/api/app.py b/src/presentation/api/app.py index 3c6781d..d087f8d 100644 --- a/src/presentation/api/app.py +++ b/src/presentation/api/app.py @@ -24,7 +24,7 @@ def create_app() -> Litestar: exception_handlers={ Exception: custom_exception_handler, ValidationError: validation_error_handler, - } + }, ) container = make_async_container( diff --git a/src/presentation/api/exception.py b/src/presentation/api/exception.py index e6e5e0d..8e7a347 100644 --- a/src/presentation/api/exception.py +++ b/src/presentation/api/exception.py @@ -8,11 +8,20 @@ logger = logging.getLogger(__name__) -def custom_exception_handler(_: Request[Any, Any, Any], exc: Exception) -> Response[Any]: +def custom_exception_handler( + _: Request[Any, Any, Any], exc: Exception +) -> Response[Any]: logger.exception(exc) - return Response({"detail": "Internal Server Error", "status_code": 500}, status_code=500) + return Response( + {"detail": "Internal Server Error", "status_code": 500}, status_code=500 + ) -def validation_error_handler(_: Request[Any, Any, Any], exc: ValidationError) -> Response[Any]: +def validation_error_handler( + _: Request[Any, Any, Any], exc: ValidationError +) -> Response[Any]: logger.exception(exc) - return Response({"detail": exc.message, "status_code": exc.status_code}, status_code=exc.status_code) + return Response( + {"detail": exc.message, "status_code": exc.status_code}, + status_code=exc.status_code, + ) diff --git a/src/presentation/api/health/__init__.py b/src/presentation/api/health/__init__.py index ccc2cd9..b22a756 100644 --- a/src/presentation/api/health/__init__.py +++ b/src/presentation/api/health/__init__.py @@ -1,5 +1,5 @@ from .router import health_router __all__ = [ - 'health_router', -] \ No newline at end of file + "health_router", +] diff --git a/uv.lock b/uv.lock index 16a4fa9..d1aa7e8 100644 --- a/uv.lock +++ b/uv.lock @@ -135,6 +135,7 @@ dependencies = [ { name = "pyyaml" }, { name = "ruff" }, { name = "sqlalchemy" }, + { name = "ty" }, { name = "uvicorn" }, ] @@ -152,6 +153,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "ruff", specifier = ">=0.12.5" }, { name = "sqlalchemy", specifier = ">=2.0.41" }, + { name = "ty", specifier = ">=0.0.1a16" }, { name = "uvicorn", specifier = ">=0.35.0" }, ] @@ -819,6 +821,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, ] +[[package]] +name = "ty" +version = "0.0.1a16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/62/f021cdbdda9dbd553be4b841c2e9329ecd3ddc630a17c1ab5179832fbca8/ty-0.0.1a16.tar.gz", hash = "sha256:9ade26904870dc9bd988e58bad4382857f75ae05edb682ee0ba2f26fcc2d4c0f", size = 3961822 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8d/fe6a4161ee493005d2d59bb02c37a746723eb65e45740cc8aa2367da5ddb/ty-0.0.1a16-py3-none-linux_armv6l.whl", hash = "sha256:dfb55d28df78ca40f8aff91ec3ae01f4b7bc23aa04c72ace7ec00fbc5e0468c0", size = 7840414 }, + { url = "https://files.pythonhosted.org/packages/88/85/70bef8b680216276e941480a0bac3d00b89d1d64d4e281bd3daaa85fc5ed/ty-0.0.1a16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a0e9917efadf2ec173ee755db3653243b64fa8b26fa4d740dea68e969a99898", size = 7979261 }, + { url = "https://files.pythonhosted.org/packages/5a/07/400b56734c7b8a29ea1d6927f36dd75bf263c8a223ea4bd05e25bdbbc8a2/ty-0.0.1a16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9253cb8b5c4052337b1600f581ecd8e6929e635a07ec9e8dc5cc2fa4008e6b3b", size = 7567959 }, + { url = "https://files.pythonhosted.org/packages/02/c9/095cb09e33a4d547a71f4f698d09f3f9edc92746e029945fe8412f59d421/ty-0.0.1a16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:374c059e184f8abc969e07965355ddbbf7205a713721d3867ee42f976249c9ac", size = 7697398 }, + { url = "https://files.pythonhosted.org/packages/48/39/e2ce5b1151dfc80659486f74113972bc994c39b8f7f39084b271d03c4b04/ty-0.0.1a16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c5364c6d1a1a3d5b8e765a730303f8e07094ab9e63682aa82f73755d92749852", size = 7681504 }, + { url = "https://files.pythonhosted.org/packages/a3/44/5c1158bd3e2e939e5b0ddb6b15c8e158870fa44915b5535909f83d4bd4ed/ty-0.0.1a16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f201ff0ab3267123b9e42cc8584a193aa76e6e0865003d1b0a41bd025f08229e", size = 8551057 }, + { url = "https://files.pythonhosted.org/packages/0d/20/2564cd89f1c06ce329ab25d91ce457d2dc00d2559c519111874811250442/ty-0.0.1a16-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57f14207d88043ba27f4b84d84dfdaa1bfbcc5170d5f50814d2997cbc3d75366", size = 8999239 }, + { url = "https://files.pythonhosted.org/packages/41/5f/64b74a8aaa080267c71a9d591b980a9c915b2439034b9075520c56ef1e4b/ty-0.0.1a16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:950c45e1d6c58e61ad77ed5d2d04f091e44b0d13e6d5d79143bb81078ab526b1", size = 8638649 }, + { url = "https://files.pythonhosted.org/packages/67/c7/80ad1c11d896cd1a52f24f0b3660ed368187ba152337b8f18b2d0591bd02/ty-0.0.1a16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad133d0eac5291d738e40052df98ca9f194e0f0433d6086a4890fd6733217969", size = 8443175 }, + { url = "https://files.pythonhosted.org/packages/94/6c/eb3c214a44bd0f6ad359c1ce28de62cbaecfd5823553a35b0163e9f3e738/ty-0.0.1a16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e59f877ef8b967c06173a7a663271a6e66edb049f0db00f7873be5e41d61d5b", size = 8278214 }, + { url = "https://files.pythonhosted.org/packages/18/ab/f44474a526f3a1ac770c8839a23fac51f93a4ad5e6ec2770d74e20bd5684/ty-0.0.1a16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4e973b8cb2c382263aaf77a40889ad236bd06ddca671cc973f9e33e8e02f0af1", size = 7591502 }, + { url = "https://files.pythonhosted.org/packages/b9/d3/825975f1277b097883ed3428c23e0e8f67ed4fffd25d00b8b60650b663cb/ty-0.0.1a16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a82d9c4b76a73aff60cab93b71f2dd83952c2eb68a86578e1db56aee8f7e338", size = 7715602 }, + { url = "https://files.pythonhosted.org/packages/f8/f0/2805b4172c46b832c2efa368731d4aa4af0aa35ce120a4726ccdb3b102a0/ty-0.0.1a16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7993f48def35f1707a2dc675bf7d08906cc5f26204b0b479746664301eda15b9", size = 8156780 }, + { url = "https://files.pythonhosted.org/packages/25/a5/f47c11a3dc52b3e148aaaa2bf7c37ea75998cfd50ad5f4b56fd2cc79c708/ty-0.0.1a16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9887ec65984e7dbf3b5e906ef44e8f47ff5351c7ac04d49e793b324d744050f", size = 8350253 }, + { url = "https://files.pythonhosted.org/packages/1e/e4/498c0bed38385d0c8babfe5707fe157700ae698d77dd9a1a8ffaaa97baea/ty-0.0.1a16-py3-none-win32.whl", hash = "sha256:4113a176a8343196d73145668460873d26ccef8766ff4e5287eec2622ce8754d", size = 7460041 }, + { url = "https://files.pythonhosted.org/packages/af/9e/5a8a690a5542405fd20cab6b0aa97a5af76de1e39582de545fac48e53f3a/ty-0.0.1a16-py3-none-win_amd64.whl", hash = "sha256:508ba4c50bc88f1a7c730d40f28d6c679696ee824bc09630c7c6763911de862a", size = 8074666 }, + { url = "https://files.pythonhosted.org/packages/dc/53/2a2eb8cc22b3e12d2040ed78d98842d0dddfa593d824b7ff60e30afe6f41/ty-0.0.1a16-py3-none-win_arm64.whl", hash = "sha256:36f53e430b5e0231d6b6672160c981eaf7f9390162380bcd1096941b2c746b5d", size = 7612948 }, +] + [[package]] name = "typing-extensions" version = "4.14.1"