diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9de1ede..e6d5d1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,4 +9,5 @@ repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.8.0 hooks: - - id: mypy \ No newline at end of file + - id: mypy + additional_dependencies: [types-requests, types-cachetools] \ No newline at end of file diff --git a/Makefile b/Makefile index c77dc69..b25687d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ FUNCTION_NAME ?= my-function-name .PHONY: dev build deploy dev: - uvicorn app.main:app --reload + uvicorn backend.main:app --reload build: rm -rf ./_package lambda.zip diff --git a/backend/adapters/inbound/api.py b/backend/adapters/inbound/api.py index 252af14..dcb7f44 100644 --- a/backend/adapters/inbound/api.py +++ b/backend/adapters/inbound/api.py @@ -1,12 +1,13 @@ -import os +from typing import cast +import requests +from cachetools import TTLCache from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel -from backend.adapters.outbound.dynamodb_repository import DynamoDBRepository from backend.application.exceptions import MemoAccessDeniedError from backend.application.ports import MemoUseCase -from backend.application.service import MemoService +from backend.dependencies import get_memo_use_case from backend.domain.memo import MemoDomainError router = APIRouter() @@ -26,24 +27,56 @@ class UpdateMemoRequest(BaseModel): content: str -def get_memo_use_case() -> MemoUseCase: - table_name = os.getenv("DYNAMODB_TABLE") - if not table_name: - raise ValueError("DYNAMODB_TABLE 환경변수가 설정되지 않았습니다") +TOKEN_CACHE = cast(TTLCache[str, str, float], TTLCache(maxsize=100, ttl=300)) - repo = DynamoDBRepository(table_name=table_name) - return MemoService(repo) + +def get_current_user_id(authorization: str | None = Header(None)) -> str: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="인증되지 않은 요청입니다. 유효한 Bearer 인증 토큰이 필요합니다.", + ) + + token = authorization.split(" ")[1] + + if token in TOKEN_CACHE: + return TOKEN_CACHE[token] + + github_user_uri = "https://api.github.com/user" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + try: + response = requests.get(github_user_uri, headers=headers, timeout=5) + + if response.status_code != 200: + raise HTTPException( + status_code=401, detail="유효하지 않거나 만료된 깃허브 토큰입니다." + ) + + user_data = response.json() + user_id = str(user_data.get("id")) + + TOKEN_CACHE[token] = user_id + return user_id + + except requests.exceptions.RequestException: + raise HTTPException( + status_code=503, detail="깃허브 인증 서버와 통신할 수 없습니다." + ) -@router.post("/add", response_model=MemoCreateResponse) +@router.post("/memos", response_model=MemoCreateResponse) def add_memo( request: MemoCreateRequest, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), use_case: MemoUseCase = Depends(get_memo_use_case), ): try: memo = use_case.create_memo( - user_id=x_user_id, + user_id=user_id, content=request.content, category=request.category, ) @@ -56,11 +89,11 @@ def add_memo( def get_memos( category: str | None = None, limit: int = 5, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): memos = service.get_all_memos( - user_id=x_user_id, + user_id=user_id, category=category, limit=limit, ) @@ -70,12 +103,12 @@ def get_memos( @router.get("/memos/{memo_id}") def get_memo( memo_id: str, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): memo = service.get_memo_by_id( memo_id=memo_id, - user_id=x_user_id, + user_id=user_id, ) if not memo: @@ -88,13 +121,13 @@ def get_memo( def update_memo( memo_id: str, request: UpdateMemoRequest, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): try: updated_memo = service.update_memo( memo_id=memo_id, - user_id=x_user_id, + user_id=user_id, content=request.content, ) return updated_memo @@ -109,11 +142,11 @@ def update_memo( @router.delete("/memos/{memo_id}") def delete_memo( memo_id: str, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): try: - service.delete_memo(memo_id=memo_id, user_id=x_user_id) + service.delete_memo(memo_id=memo_id, user_id=user_id) return {"message": "메모 삭제 완료"} except MemoAccessDeniedError as e: raise HTTPException(status_code=403, detail=str(e)) diff --git a/backend/dependencies.py b/backend/dependencies.py new file mode 100644 index 0000000..12338a3 --- /dev/null +++ b/backend/dependencies.py @@ -0,0 +1,14 @@ +import os + +from backend.adapters.outbound.dynamodb_repository import DynamoDBRepository +from backend.application.ports import MemoUseCase +from backend.application.service import MemoService + + +def get_memo_use_case() -> MemoUseCase: + table_name = os.getenv("DYNAMODB_TABLE") + if not table_name: + raise ValueError("DYNAMODB_TABLE 환경 변수가 설정되지 않았습니다.") + + repo = DynamoDBRepository(table_name=table_name) + return MemoService(repo) diff --git a/cli/adapters/__init__.py b/cli/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/adapters/inbound/__init__.py b/cli/adapters/inbound/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/adapters/inbound/cli_adapter.py b/cli/adapters/inbound/cli_adapter.py new file mode 100644 index 0000000..ce77eba --- /dev/null +++ b/cli/adapters/inbound/cli_adapter.py @@ -0,0 +1,149 @@ +import argparse + +from cli.application.exceptions import ( + AuthDeviceFlowError, + AuthenticationError, +) +from cli.dependencies import container + + +def main(): + parser = argparse.ArgumentParser(description="mylog CLI", add_help=False) + parser.add_argument("-h", "--help", action="store_true", help="도움말 출력") + + subparsers = parser.add_subparsers(dest="command") + + subparsers.add_parser("login", help="깃허브 계정으로 로그인합니다.") + subparsers.add_parser("logout", help="로그아웃합니다.") + + add_parser = subparsers.add_parser("add", help="새로운 메모를 기록합니다.") + add_parser.add_argument("content", type=str, help="저장할 메모 내용") + add_parser.add_argument( + "-c", "--category", type=str, default=None, help="카테고리 지정" + ) + + list_parser = subparsers.add_parser("list", help="저장된 메모를 조회합니다.") + list_parser.add_argument( + "category", type=str, nargs="?", default=None, help="조회할 카테고리" + ) + list_parser.add_argument( + "-l", "--limit", type=int, default=5, help="출력 개수 지정" + ) + + update_parser = subparsers.add_parser("update", help="기존 메모를 수정합니다.") + update_parser.add_argument("id", type=str, help="수정할 메모 고유 ID") + update_parser.add_argument("content", type=str, help="새로운 내용") + + rm_parser = subparsers.add_parser("rm", help="메모를 삭제합니다.") + rm_parser.add_argument("id", type=str, help="삭제할 메모 고유 ID") + + subparsers.add_parser("help", help="도움말 매뉴얼을 출력합니다.") + + args, unknown = parser.parse_known_args() + + if args.help or args.command == "help" or args.command is None: + print("""Usage (사용 예시): + +mylog [options] + +Commands: +login 깃허브 계정으로 로그인하여 클라우드 환경을 연동합니다. +add 새로운 메모를 기록합니다. (-c 카테고리 지정) +list 저장된 메모를 조회합니다. (-l 개수) +update 기존 메모의 내용을 수정합니다. (ID 필요) +rm 저장된 메모를 삭제합니다. (ID 필요) +help 현재 화면의 도움말 매뉴얼을 출력합니다.""") + return + + if args.command == "login": + try: + print("GitHub 서버에 로그인 요청 코드를 생성하는 중...") + login_data = container.auth_service.request_login() + + print("\n" + "=" * 60) + print("[GitHub 로그인 안내]") + print("1. 웹 브라우저를 열고 다음 주소로 접속하세요.") + print(f" {login_data['verification_uri']}") + print("2. 아래 코드를 입력하고 승인을 완료하세요.") + print(f" [ {login_data['user_code']} ]") + print("=" * 60 + "\n") + print("사용자의 브라우저 로그인을 기다리는 중입니다...") + + if container.auth_service.complete_login( + login_data["device_code"], login_data["interval"] + ): + print("\n[로그인 성공]") + print("깃허브 토큰이 안전하게 암호화 되어 저장되었습니다.") + else: + print("\n[로그인 실패] 깃허브 토큰을 획득하지 못했습니다.") + except AuthDeviceFlowError as e: + print(f"\n[로그인 실패] {e}") + except Exception as e: + print(f"로그인 예외 발생: {e}") + + elif args.command == "logout": + try: + if container.auth_service.logout(): + print("토큰이 안전하게 파기되었습니다. (로그아웃 성공)") + else: + print("이미 로그아웃된 상태거나 삭제할 토큰이 없습니다.") + except Exception as e: + print(f"로그아웃 실패: {e}") + + elif args.command == "add": + try: + container.memo_service.save_memo( + content=args.content, + category=args.category, + ) + cat_title = args.category if args.category else "basic" + print(f"\n> [{cat_title}] 메모가 저장되었습니다.") + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[저장 실패] {e}") + + elif args.command == "list": + try: + memos = container.memo_service.get_memos( + category=args.category, + limit=args.limit, + ) + + cat_title = args.category if args.category else "전체" + print(f"\n> [{cat_title}] 최근 메모 ({len(memos)}개 출력)") + for m in memos: + print( + f" [ID: {m.get('id', 'N/A')}] " + f"{m.get('created_at', '')} | " + f"{m.get('content', '')}" + ) + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[조회 실패] {e}") + + elif args.command == "update": + try: + container.memo_service.update_memo( + memo_id=args.id, + content=args.content, + ) + print(f"\n> [{args.id}] 메모가 성공적으로 수정되었습니다.") + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[수정 실패] {e}") + + elif args.command == "rm": + try: + container.memo_service.delete_memo(memo_id=args.id) + print(f"\n> {args.id}번 메모가 깔끔하게 삭제되었습니다.") + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[삭제 실패] {e}") + + +if __name__ == "__main__": + main() diff --git a/cli/adapters/outbound/__init__.py b/cli/adapters/outbound/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/adapters/outbound/github_auth_adapter.py b/cli/adapters/outbound/github_auth_adapter.py new file mode 100644 index 0000000..b10cc59 --- /dev/null +++ b/cli/adapters/outbound/github_auth_adapter.py @@ -0,0 +1,51 @@ +import time + +import requests + +from cli.application.ports.outbound import AuthRepositoryPort + + +class GitHubDeviceAuthAdapter(AuthRepositoryPort): + DEVICE_CODE_URL = "https://github.com/login/device/code" + ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + + def __init__(self, client_id: str): + self.client_id = client_id + + def request_device_code(self) -> dict: + headers = {"Accept": "application/json"} + payload = { + "client_id": self.client_id, + "scope": "read:user", + } + response = requests.post( + self.DEVICE_CODE_URL, json=payload, headers=headers, timeout=5 + ) + response.raise_for_status() + return response.json() + + def poll_for_token(self, device_code: str, interval: int = 5) -> dict: + headers = {"Accept": "application/json"} + payload = { + "client_id": self.client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + while True: + time.sleep(interval) + response = requests.post( + self.ACCESS_TOKEN_URL, json=payload, headers=headers, timeout=5 + ) + data = response.json() + + if "access_token" in data: + return data + + error = data.get("error") + if error == "authorization_pending": + continue + elif error == "slow_down": + interval += 5 + else: + raise RuntimeError(f"Github 인증 실패: {error}") diff --git a/cli/adapters/outbound/http_memo_adapter.py b/cli/adapters/outbound/http_memo_adapter.py new file mode 100644 index 0000000..5217ae8 --- /dev/null +++ b/cli/adapters/outbound/http_memo_adapter.py @@ -0,0 +1,56 @@ +import requests + +from cli.application.ports.outbound import MemoRepositoryPort + + +class HttpMemoRepositoryAdapter(MemoRepositoryPort): + def __init__(self, api_base_url: str): + self.api_base_url = api_base_url + + def _get_headers(self, token: str) -> dict: + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + + def save(self, token: str, content: str, category: str | None = None) -> dict: + url = f"{self.api_base_url}/memos" + response = requests.post( + url, + json={"content": content, "category": category}, + headers=self._get_headers(token), + timeout=5, + ) + response.raise_for_status() + return response.json() + + def get_all(self, token: str, category: str | None = None, limit: int = 5) -> list: + url = f"{self.api_base_url}/memos" + params: dict[str, str | int] = {"limit": limit} + if category: + params["category"] = category + + response = requests.get( + url, headers=self._get_headers(token), params=params, timeout=5 + ) + + response.raise_for_status() + data = response.json() + + if isinstance(data, dict) and "memos" in data: + return data["memos"] + return data if isinstance(data, list) else [] + + def update(self, memo_id: str, token: str, content: str) -> dict: + url = f"{self.api_base_url}/memos/{memo_id}" + response = requests.put( + url, json={"content": content}, headers=self._get_headers(token), timeout=5 + ) + response.raise_for_status() + return response.json() + + def delete(self, memo_id: str, token: str) -> dict: + url = f"{self.api_base_url}/memos/{memo_id}" + response = requests.delete(url, headers=self._get_headers(token), timeout=5) + response.raise_for_status() + return response.json() diff --git a/cli/adapters/outbound/keyring_token_adapter.py b/cli/adapters/outbound/keyring_token_adapter.py new file mode 100644 index 0000000..1dc53c4 --- /dev/null +++ b/cli/adapters/outbound/keyring_token_adapter.py @@ -0,0 +1,24 @@ +import keyring + +from cli.application.ports.outbound import TokenStoragePort + + +class KeyringTokenAdapter(TokenStoragePort): + def __init__( + self, service_name: str = "mylog-app", username: str = "user_secure_token" + ): + self.service_name = service_name + self.username = username + + def get_token(self) -> str | None: + return keyring.get_password(self.service_name, self.username) + + def save_token(self, token: str) -> None: + keyring.set_password(self.service_name, self.username, token) + + def delete_token(self) -> bool: + try: + keyring.delete_password(self.service_name, self.username) + return True + except keyring.errors.PasswordDeleteError: + return False diff --git a/cli/application/__init__.py b/cli/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/application/exceptions.py b/cli/application/exceptions.py new file mode 100644 index 0000000..5a86164 --- /dev/null +++ b/cli/application/exceptions.py @@ -0,0 +1,10 @@ +class ApplicationError(Exception): + pass + + +class AuthenticationError(ApplicationError): + pass + + +class AuthDeviceFlowError(AuthenticationError): + pass diff --git a/cli/application/ports/__init__.py b/cli/application/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/application/ports/inbound.py b/cli/application/ports/inbound.py new file mode 100644 index 0000000..92e6d29 --- /dev/null +++ b/cli/application/ports/inbound.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod + + +class MemoUseCase(ABC): + @abstractmethod + def save_memo(self, content: str, category: str | None = None) -> dict: + pass + + @abstractmethod + def get_memos(self, category: str | None = None, limit: int = 5) -> list: + pass + + @abstractmethod + def update_memo(self, memo_id: str, content: str) -> dict: + pass + + @abstractmethod + def delete_memo(self, memo_id: str) -> dict: + pass + + +class AuthUseCase(ABC): + @abstractmethod + def request_login(self) -> dict: + pass + + @abstractmethod + def complete_login(self, device_code: str, interval: int) -> bool: + pass + + @abstractmethod + def logout(self) -> bool: + pass diff --git a/cli/application/ports/outbound.py b/cli/application/ports/outbound.py new file mode 100644 index 0000000..f350567 --- /dev/null +++ b/cli/application/ports/outbound.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod + + +class TokenStoragePort(ABC): + @abstractmethod + def get_token(self) -> str | None: + pass + + @abstractmethod + def save_token(self, token: str) -> None: + pass + + @abstractmethod + def delete_token(self) -> bool: + pass + + +class MemoRepositoryPort(ABC): + @abstractmethod + def save(self, token: str, content: str, category: str | None = None) -> dict: + pass + + @abstractmethod + def get_all(self, token: str, category: str | None = None, limit: int = 5) -> list: + pass + + @abstractmethod + def update(self, memo_id: str, token: str, content: str) -> dict: + pass + + @abstractmethod + def delete(self, memo_id: str, token: str) -> dict: + pass + + +class AuthRepositoryPort(ABC): + @abstractmethod + def request_device_code(self) -> dict: + pass + + @abstractmethod + def poll_for_token(self, device_code: str, interval: int = 5) -> dict: + pass diff --git a/cli/application/service.py b/cli/application/service.py new file mode 100644 index 0000000..f7ec622 --- /dev/null +++ b/cli/application/service.py @@ -0,0 +1,74 @@ +from cli.application.exceptions import AuthDeviceFlowError, AuthenticationError +from cli.application.ports.inbound import AuthUseCase, MemoUseCase +from cli.application.ports.outbound import ( + AuthRepositoryPort, + MemoRepositoryPort, + TokenStoragePort, +) + + +class MemoService(MemoUseCase): + def __init__(self, memo_repo: MemoRepositoryPort, token_storage: TokenStoragePort): + self.memo_repo = memo_repo + self.token_storage = token_storage + + def _require_token(self) -> str: + token = self.token_storage.get_token() + if not token: + raise AuthenticationError( + "유효한 로그인 정보가 없습니다.\n" + "'mylog login' 명령어를 실행해 다시 로그인해 주세요." + ) + return token + + def save_memo(self, content: str, category: str | None = None) -> dict: + token = self._require_token() + return self.memo_repo.save(token=token, content=content, category=category) + + def get_memos(self, category: str | None = None, limit: int = 5) -> list: + token = self._require_token() + return self.memo_repo.get_all(token=token, category=category, limit=limit) + + def update_memo(self, memo_id: str, content: str) -> dict: + token = self._require_token() + return self.memo_repo.update(memo_id=memo_id, token=token, content=content) + + def delete_memo(self, memo_id: str) -> dict: + token = self._require_token() + return self.memo_repo.delete(memo_id=memo_id, token=token) + + +class AuthService(AuthUseCase): + def __init__(self, auth_repo: AuthRepositoryPort, token_storage: TokenStoragePort): + self.auth_repo = auth_repo + self.token_storage = token_storage + + def request_login(self) -> dict: + try: + device_data = self.auth_repo.request_device_code() + return { + "user_code": device_data.get("user_code"), + "verification_uri": device_data.get("verification_uri"), + "device_code": device_data.get("device_code"), + "interval": device_data.get("interval", 5), + } + except Exception as e: + raise AuthDeviceFlowError(f"깃허브 로그인 요청 실패: {e}") from e + + def complete_login(self, device_code: str, interval: int) -> bool: + try: + token_data = self.auth_repo.poll_for_token( + device_code=device_code, + interval=interval, + ) + access_token = token_data.get("access_token") + + if access_token: + self.token_storage.save_token(access_token) + return True + return False + except Exception as e: + raise AuthDeviceFlowError(str(e)) from e + + def logout(self) -> bool: + return self.token_storage.delete_token() diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000..51f9dc7 --- /dev/null +++ b/cli/config.py @@ -0,0 +1,26 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + + +class Settings: + def __init__(self): + self.API_BASE_URL = os.getenv("API_BASE_URL") + self.GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") + + self.validate() + + def validate(self): + if not self.API_BASE_URL: + raise EnvironmentError( + "에러: 환경변수 API_BASE_URL이 설정되어 있지 않습니다" + ) + if not self.GITHUB_CLIENT_ID: + raise EnvironmentError( + "에러: 환경변수 GITHUB_CLIENT_ID가 설정되어 있지 않습니다." + ) + + +settings = Settings() diff --git a/cli/dependencies.py b/cli/dependencies.py new file mode 100644 index 0000000..ce308d6 --- /dev/null +++ b/cli/dependencies.py @@ -0,0 +1,27 @@ +from cli.adapters.outbound.github_auth_adapter import GitHubDeviceAuthAdapter +from cli.adapters.outbound.http_memo_adapter import HttpMemoRepositoryAdapter +from cli.adapters.outbound.keyring_token_adapter import KeyringTokenAdapter +from cli.application.ports.inbound import AuthUseCase, MemoUseCase +from cli.application.service import AuthService, MemoService +from cli.config import settings + + +class Container: + def __init__(self): + self.token_storage = KeyringTokenAdapter() + self.memo_repo = HttpMemoRepositoryAdapter(api_base_url=settings.API_BASE_URL) + self.auth_repo = GitHubDeviceAuthAdapter( + client_id=settings.GITHUB_CLIENT_ID, + ) + + self.memo_service: MemoUseCase = MemoService( + memo_repo=self.memo_repo, + token_storage=self.token_storage, + ) + self.auth_service: AuthUseCase = AuthService( + auth_repo=self.auth_repo, + token_storage=self.token_storage, + ) + + +container = Container() diff --git a/cli/domain/__init__.py b/cli/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/mylog.py b/cli/mylog.py deleted file mode 100644 index 965fc37..0000000 --- a/cli/mylog.py +++ /dev/null @@ -1,45 +0,0 @@ -import argparse -import json -import urllib.request - -API_URL = "https://4roukuec13.execute-api.ap-northeast-2.amazonaws.com/v1/add" - - -def send_to_aws(content): - payload = {"category": "cli", "content": content} - json_data = json.dumps(payload).encode("utf-8") - - req = urllib.request.Request( - url=API_URL, data=json_data, headers={"content-type": "application/json"} - ) - - try: - with urllib.request.urlopen(req) as response: - result = json.loads(response.read().decode("utf-8")) - print("[aws 저장 성공]") - print(f"서버의 응답 메시지: {result['message']}") - print(f"저장된 고유 ID: {result['id']}") - except Exception as e: - print(f"서버 통신중 에러 발생: {e}") - - -def main(): - parser = argparse.ArgumentParser(description="나만의 터미널 메모장") - subparsers = parser.add_subparsers( - dest="command", help="사용할 명령어를 입력하세요" - ) - - add_parser = subparsers.add_parser("add", help="새로운 메모를 저장합니다.") - add_parser.add_argument("content", type=str, help="저장할 메모 내용을 입력하세요") - - args = parser.parse_args() - - if args.command == "add": - print(f"입력하신 '{args.content} 문장을 클라우드로 전송'") - send_to_aws(args.content) - else: - parser.print_help() - - -if __name__ == "__main__": - main() diff --git a/cli/setup.py b/cli/setup.py new file mode 100644 index 0000000..0ec14d9 --- /dev/null +++ b/cli/setup.py @@ -0,0 +1,17 @@ +from setuptools import find_packages, setup + +setup( + name="mylog", + version="0.1.0", + packages=find_packages(include=["cli", "cli.*"]), + install_requires=[ + "requests", + "keyring", + "python-dotenv", + ], + entry_points={ + "console_scripts": [ + "mylog=cli.adapters.inbound.cli_adapter:main", + ], + }, +) diff --git a/handler.py b/handler.py index c59fec3..ba61f50 100644 --- a/handler.py +++ b/handler.py @@ -1,4 +1,5 @@ -from app.main import app from mangum import Mangum +from backend.main import app + handler = Mangum(app) diff --git a/pyproject.toml b/pyproject.toml index 4f0256f..8aca273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ dependencies = [ "uvicorn[standard]>=0.34.0", "mangum>=0.19.0", "python-ulid>=3.1.0", + "requests", + "cachetools>=5.3.0", ] [dependency-groups] @@ -33,3 +35,6 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "backend.*" ignore_missing_imports = false + +[tool.setuptools.packages.find] +include = ["cli*"] \ No newline at end of file diff --git a/uv.lock b/uv.lock index e9ac26e..51a54d3 100644 --- a/uv.lock +++ b/uv.lock @@ -41,9 +41,11 @@ name = "api-server" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "cachetools" }, { name = "fastapi" }, { name = "mangum" }, { name = "python-ulid" }, + { name = "requests" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -59,9 +61,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=5.3.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "mangum", specifier = ">=0.19.0" }, { name = "python-ulid", specifier = ">=3.1.0" }, + { name = "requests" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, ] @@ -143,6 +147,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -152,6 +174,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -698,6 +768,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.13"