diff --git a/.gitignore b/.gitignore index c118903..6549454 100644 --- a/.gitignore +++ b/.gitignore @@ -1,210 +1,215 @@ -example.py -*.db - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +example.py +*.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# claude +.claude/ + +.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json index e43741c..9f602a2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,28 +1,26 @@ -{ - "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", - "python.testing.cwd": "./", - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, - "pythonTestExplorer.testFramework": "pytest", - "python.testing.pytestPath": "./.venv/Scripts/pytest.exe", - "python.testing.pytestArgs": [ - "${workspaceFolder}/tests", - "${workspaceFolder}/appserver" - ], - "python.analysis.autoImportCompletions": true, - "python.analysis.indexing": true, - "python.analysis.completeFunctionParens": true, - "python.analysis.autoSearchPaths": true, - "python.analysis.useLibraryCodeForTypes": true, - "python.analysis.typeCheckingMode": "off", - "python.analysis.include": [ - "./appserver" - ], - "python.analysis.extraPaths": [ - "./appserver" - ], - "python.autoComplete.extraPaths": [ - "./appserver" - ], - "python.terminal.activateEnvironment": true, -} \ No newline at end of file +๏ปฟ{ + "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", + "python.testing.cwd": "${workspaceFolder}", + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "pythonTestExplorer.testFramework": "pytest", + "python.testing.pytestPath": "./.venv/Scripts/pytest.exe", + "python.testing.pytestArgs": [ + "." + ], + "python.testing.autoTestDiscoverOnSaveEnabled": false, + "python.analysis.autoImportCompletions": true, + "python.analysis.indexing": true, + "python.analysis.completeFunctionParens": true, + "python.analysis.autoSearchPaths": true, + "python.analysis.useLibraryCodeForTypes": true, + "python.analysis.typeCheckingMode": "off", + "python.analysis.extraPaths": [ + "./appserver" + ], + "python.autoComplete.extraPaths": [ + "./appserver" + ], + "python.terminal.activateEnvironment": true, + "python-envs.pythonProjects": [] +} diff --git a/README.md b/README.md index 55b2740..173cc60 100644 --- a/README.md +++ b/README.md @@ -1,627 +1,627 @@ -# fastapi-project-study - -## ๐Ÿ“š Pytest Fixture ์™„๋ฒฝ ๊ฐ€์ด๋“œ - -### ๐Ÿค” Fixture๋ž€? - -**Fixture**๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ ์ „์— ํ•„์š”ํ•œ **์ค€๋น„ ์ž‘์—…(setup)**๊ณผ ํ…Œ์ŠคํŠธ ํ›„ **์ •๋ฆฌ ์ž‘์—…(teardown)**์„ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ด์ฃผ๋Š” **pytest์˜ ํ…Œ์ŠคํŠธ ์ „์šฉ ๊ธฐ๋Šฅ**์ž…๋‹ˆ๋‹ค. - -์‰ฝ๊ฒŒ ๋งํ•˜๋ฉด: -- ํ…Œ์ŠคํŠธ์— ํ•„์š”ํ•œ **์žฌ๋ฃŒ(๋ฐ์ดํ„ฐ, ๊ฐ์ฒด, ์—ฐ๊ฒฐ ๋“ฑ)**๋ฅผ ๋ฏธ๋ฆฌ ์ค€๋น„ํ•ด์ฃผ๋Š” ํ•จ์ˆ˜ -- ํ…Œ์ŠคํŠธ๊ฐ€ ๋๋‚˜๋ฉด ์ž๋™์œผ๋กœ **์ •๋ฆฌ**๊นŒ์ง€ ํ•ด์คŒ -- ์—ฌ๋Ÿฌ ํ…Œ์ŠคํŠธ์—์„œ **์žฌ์‚ฌ์šฉ** ๊ฐ€๋Šฅ - -> โš ๏ธ **์ค‘์š”**: Fixture๋Š” **ํ…Œ์ŠคํŠธ ์ฝ”๋“œ(`tests/` ๋””๋ ‰ํ† ๋ฆฌ)์—์„œ๋งŒ** ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ(`appserver/`)์—์„œ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค! - ---- - -### ๐ŸŽฏ ์™œ Fixture๋ฅผ ์‚ฌ์šฉํ•˜๋‚˜? - -#### โŒ Fixture ์—†์ด ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๋ฉด: - -```python -def test_user_creation(): - # ๋งค๋ฒˆ DB ์—ฐ๊ฒฐ ์„ค์ • - engine = create_engine("sqlite:///:memory:") - SQLModel.metadata.create_all(engine) - session = Session(engine) - - # ์‹ค์ œ ํ…Œ์ŠคํŠธ - user = User(username="test") - session.add(user) - session.commit() - - # ์ •๋ฆฌ - session.close() - engine.dispose() - -def test_user_query(): - # ๋˜ ๋˜‘๊ฐ™์ด DB ์—ฐ๊ฒฐ ์„ค์ • (์ค‘๋ณต!) - engine = create_engine("sqlite:///:memory:") - SQLModel.metadata.create_all(engine) - session = Session(engine) - - # ์‹ค์ œ ํ…Œ์ŠคํŠธ - user = session.query(User).first() - - # ์ •๋ฆฌ - session.close() - engine.dispose() -``` - -**๋ฌธ์ œ์ :** -- ์ฝ”๋“œ ์ค‘๋ณต์ด ์‹ฌํ•จ -- ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ๊ธธ๊ณ  ๋ณต์žกํ•จ -- ์ •๋ฆฌ ์ž‘์—…์„ ๊นœ๋นกํ•˜๋ฉด ๋ฆฌ์†Œ์Šค ๋ˆ„์ˆ˜ ๋ฐœ์ƒ - ---- - -#### โœ… Fixture ์‚ฌ์šฉํ•˜๋ฉด: - -```python -@pytest.fixture -def db_session(): - # Setup: ์ค€๋น„ ์ž‘์—… - engine = create_engine("sqlite:///:memory:") - SQLModel.metadata.create_all(engine) - session = Session(engine) - - yield session # ํ…Œ์ŠคํŠธ์— session ์ œ๊ณต - - # Teardown: ์ •๋ฆฌ ์ž‘์—… (์ž๋™ ์‹คํ–‰) - session.close() - engine.dispose() - -# ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜์—์„œ fixture ์ด๋ฆ„์„ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์œผ๋ฉด ์ž๋™ ์ฃผ์ž…! -def test_user_creation(db_session): - user = User(username="test") - db_session.add(user) - db_session.commit() - # ์ •๋ฆฌ๋Š” ์ž๋™์œผ๋กœ ๋จ! - -def test_user_query(db_session): - user = db_session.query(User).first() - # ์ •๋ฆฌ๋Š” ์ž๋™์œผ๋กœ ๋จ! -``` - -**์žฅ์ :** -- ์ฝ”๋“œ ์ค‘๋ณต ์ œ๊ฑฐ -- ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ๊ฐ„๊ฒฐํ•ด์ง -- ์ •๋ฆฌ ์ž‘์—… ์ž๋™ํ™” (๋ฆฌ์†Œ์Šค ๋ˆ„์ˆ˜ ๋ฐฉ์ง€) - ---- - -### ๐Ÿ”ง Fixture ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ• - -#### 1๏ธโƒฃ **๊ธฐ๋ณธ Fixture ์ •์˜** - -```python -import pytest - -@pytest.fixture -def sample_data(): - """๊ฐ„๋‹จํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•˜๋Š” fixture""" - return {"name": "Alice", "age": 30} - -def test_sample(sample_data): - # sample_data fixture๊ฐ€ ์ž๋™์œผ๋กœ ์ฃผ์ž…๋จ - assert sample_data["name"] == "Alice" - assert sample_data["age"] == 30 -``` - -**์ž‘๋™ ๋ฐฉ์‹:** -1. pytest๊ฐ€ `test_sample` ํ•จ์ˆ˜๋ฅผ ๋ฐœ๊ฒฌ -2. ํŒŒ๋ผ๋ฏธํ„ฐ `sample_data`๋ฅผ ํ™•์ธ -3. ๊ฐ™์€ ์ด๋ฆ„์˜ fixture๋ฅผ ์ฐพ์•„์„œ ์‹คํ–‰ -4. fixture์˜ ๋ฐ˜ํ™˜๊ฐ’์„ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜์— ์ „๋‹ฌ - ---- - -#### 2๏ธโƒฃ **Setup/Teardown์ด ์žˆ๋Š” Fixture** - -```python -@pytest.fixture -def temp_file(): - # Setup: ํŒŒ์ผ ์ƒ์„ฑ - file_path = "test_temp.txt" - with open(file_path, "w") as f: - f.write("test data") - - yield file_path # ํ…Œ์ŠคํŠธ์— ํŒŒ์ผ ๊ฒฝ๋กœ ์ œ๊ณต - - # Teardown: ํŒŒ์ผ ์‚ญ์ œ (ํ…Œ์ŠคํŠธ ํ›„ ์ž๋™ ์‹คํ–‰) - import os - os.remove(file_path) - -def test_file_read(temp_file): - with open(temp_file, "r") as f: - content = f.read() - assert content == "test data" - # ํ…Œ์ŠคํŠธ ๋๋‚˜๋ฉด ํŒŒ์ผ์ด ์ž๋™์œผ๋กœ ์‚ญ์ œ๋จ! -``` - -**`yield` ํ‚ค์›Œ๋“œ:** -- `yield` ์•ž: Setup (ํ…Œ์ŠคํŠธ ์ „ ์‹คํ–‰) -- `yield` ๊ฐ’: ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜์— ์ „๋‹ฌ -- `yield` ๋’ค: Teardown (ํ…Œ์ŠคํŠธ ํ›„ ์‹คํ–‰) - ---- - -#### 3๏ธโƒฃ **Fixture๊ฐ€ ๋‹ค๋ฅธ Fixture๋ฅผ ์‚ฌ์šฉ** - -```python -@pytest.fixture -def database_engine(): - engine = create_engine("sqlite:///:memory:") - yield engine - engine.dispose() - -@pytest.fixture -def database_session(database_engine): # ๋‹ค๋ฅธ fixture๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ! - session = Session(database_engine) - yield session - session.close() - -def test_with_session(database_session): - # database_engine โ†’ database_session โ†’ test ์ˆœ์„œ๋กœ ์‹คํ–‰ - user = User(username="test") - database_session.add(user) -``` - -**์˜์กด์„ฑ ์ฒด์ธ:** -``` -database_engine (๋จผ์ € ์‹คํ–‰) - โ†“ -database_session (engine์„ ๋ฐ›์•„์„œ ์‹คํ–‰) - โ†“ -test_with_session (session์„ ๋ฐ›์•„์„œ ์‹คํ–‰) -``` - ---- - -### ๐ŸŽจ Fixture Scope (๋ฒ”์œ„) - -Fixture๊ฐ€ **์–ธ์ œ ์ƒ์„ฑ๋˜๊ณ  ์–ธ์ œ ์ •๋ฆฌ๋˜๋Š”์ง€** ์ œ์–ดํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. - -```python -@pytest.fixture(scope="function") # ๊ธฐ๋ณธ๊ฐ’: ๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ์ƒˆ๋กœ ์ƒ์„ฑ -def function_scope(): - print("Setup") - yield "data" - print("Teardown") - -@pytest.fixture(scope="module") # ๋ชจ๋“ˆ(ํŒŒ์ผ)๋‹น 1๋ฒˆ๋งŒ ์ƒ์„ฑ -def module_scope(): - print("Setup once per module") - yield "data" - print("Teardown once per module") - -@pytest.fixture(scope="session") # ์ „์ฒด ํ…Œ์ŠคํŠธ ์„ธ์…˜๋‹น 1๋ฒˆ๋งŒ ์ƒ์„ฑ -def session_scope(): - print("Setup once per session") - yield "data" - print("Teardown once per session") -``` - -| Scope | ์ƒ์„ฑ ์‹œ์  | ์ •๋ฆฌ ์‹œ์  | ์‚ฌ์šฉ ์˜ˆ์‹œ | -|-------|----------|----------|----------| -| `function` | ๊ฐ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜๋งˆ๋‹ค | ๊ฐ ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | DB ์„ธ์…˜, ์ž„์‹œ ํŒŒ์ผ | -| `class` | ๊ฐ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋งˆ๋‹ค | ํด๋ž˜์Šค ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | ํด๋ž˜์Šค๋ณ„ ์„ค์ • | -| `module` | ๊ฐ ํ…Œ์ŠคํŠธ ํŒŒ์ผ๋งˆ๋‹ค | ํŒŒ์ผ ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | DB ์—ฐ๊ฒฐ ํ’€ | -| `session` | ์ „์ฒด ํ…Œ์ŠคํŠธ ์‹œ์ž‘ ์‹œ | ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | ํ…Œ์ŠคํŠธ ์„œ๋ฒ„ | - ---- - -### ๐ŸŽจ Fixture ๊ณ ๊ธ‰ ์˜ต์…˜ - -#### 1๏ธโƒฃ **`autouse=True` - ์ž๋™ ์‹คํ–‰** - -๋ชจ๋“  ํ…Œ์ŠคํŠธ์—์„œ **์ž๋™์œผ๋กœ ์‹คํ–‰**๋˜์–ด์•ผ ํ•˜๋Š” fixture๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด ์˜ต์…˜์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. -ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ช…์‹œํ•˜์ง€ ์•Š์•„๋„ ์ž๋™์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. - -```python -@pytest.fixture(autouse=True) -def setup_log(): - """๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ „์— ์ž๋™์œผ๋กœ ์‹คํ–‰""" - print("\n[LOG] ํ…Œ์ŠคํŠธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค...") - yield - print("[LOG] ํ…Œ์ŠคํŠธ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค...") - -def test_something(): - # setup_log๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์ง€ ์•Š์•„๋„ ์ž๋™ ์‹คํ–‰๋จ! - assert True -``` - -**์‚ฌ์šฉ ์˜ˆ์‹œ:** -- ๋กœ๊น… ์„ค์ • -- ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ดˆ๊ธฐํ™” -- ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” (์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ `db_session`์ฒ˜๋Ÿผ) - -```python -# ์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ ์‹ค์ œ ์˜ˆ์‹œ -@pytest.fixture(autouse=True) # ๋ชจ๋“  ํ…Œ์ŠคํŠธ์—์„œ ์ž๋™์œผ๋กœ DB ์„ธ์…˜ ์ƒ์„ฑ -async def db_session(): - # ... -``` - ---- - -#### 2๏ธโƒฃ **`name` - Fixture ์ด๋ฆ„ ์ง€์ •** - -Fixture ํ•จ์ˆ˜ ์ด๋ฆ„์ด ๋„ˆ๋ฌด ๊ธธ๊ฑฐ๋‚˜, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ ๋” ์ง๊ด€์ ์ธ ์ด๋ฆ„์„ ์“ฐ๊ณ  ์‹ถ์„ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. - -```python -@pytest.fixture(name="db") -def a_very_long_database_session_fixture_name(): - """ํ•จ์ˆ˜๋ช…์€ ๊ธธ์ง€๋งŒ, 'db'๋ผ๋Š” ์งง์€ ์ด๋ฆ„์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ""" - engine = create_engine("sqlite:///:memory:") - session = Session(engine) - yield session - session.close() - -def test_user(db): # ํ•จ์ˆ˜๋ช… ๋Œ€์‹  'db'๋ผ๋Š” ์ด๋ฆ„์œผ๋กœ ์ฃผ์ž…! - user = User(username="test") - db.add(user) - assert db is not None -``` - -**์–ธ์ œ ์‚ฌ์šฉํ•˜๋‚˜:** -- ํ•จ์ˆ˜๋ช…์ด ๋„ˆ๋ฌด ๊ธธ ๋•Œ -- ๋” ์ง๊ด€์ ์ธ ์ด๋ฆ„์„ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์„ ๋•Œ -- ๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด - ---- - -#### 3๏ธโƒฃ **๋น„๋™๊ธฐ Fixture ์ฃผ์˜์‚ฌํ•ญ** - -FastAPI๋Š” ๋น„๋™๊ธฐ(`async/await`)๋ฅผ ๋งŽ์ด ์‚ฌ์šฉํ•˜๋ฏ€๋กœ, Fixture์—์„œ๋„ ๋น„๋™๊ธฐ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ์ฃผ์˜๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. - -**ํ•„์ˆ˜ ์„ค์ •:** - -1. **`pytest-asyncio` ์„ค์น˜** - ```bash - pip install pytest-asyncio - ``` - -2. **`pyproject.toml` ์„ค์ •** - ```toml - [tool.pytest.ini_options] - asyncio_mode = "auto" # ๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ ์ž๋™ ๊ฐ์ง€ - ``` - -3. **๋น„๋™๊ธฐ Fixture ์ž‘์„ฑ** - ```python - @pytest.fixture - async def async_client(): - """๋น„๋™๊ธฐ fixture๋Š” async def๋กœ ์ •์˜""" - async with AsyncClient(app=app, base_url="http://test") as client: - yield client - - async def test_api(async_client): - """๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜๋„ async def๋กœ ์ •์˜""" - response = await async_client.get("/users/test") - assert response.status_code == 200 - ``` - -**์ฃผ์˜์‚ฌํ•ญ:** - -| ๊ตฌ๋ถ„ | ๋™๊ธฐ Fixture | ๋น„๋™๊ธฐ Fixture | -|------|-------------|---------------| -| ์ •์˜ | `def fixture()` | `async def fixture()` | -| ์‚ฌ์šฉ | ๋™๊ธฐ ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ | ๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ | -| ์˜์กด์„ฑ | ๋™๊ธฐ fixture๋งŒ ์˜์กด ๊ฐ€๋Šฅ | ๋น„๋™๊ธฐ/๋™๊ธฐ ๋ชจ๋‘ ์˜์กด ๊ฐ€๋Šฅ | - -**์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ ์˜ˆ์‹œ:** -```python -# โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ -@pytest.fixture -async def db_session(db_engine): # ๋น„๋™๊ธฐ fixture - async with session_factory() as session: - yield session - -async def test_user(db_session): # ๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ - user = User(username="test") - db_session.add(user) - await db_session.commit() # await ์‚ฌ์šฉ - -# โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ -def test_user(db_session): # ๋™๊ธฐ ํ…Œ์ŠคํŠธ์—์„œ ๋น„๋™๊ธฐ fixture ์‚ฌ์šฉ ๋ถˆ๊ฐ€ - await db_session.commit() # SyntaxError! -``` - -**๋™๊ธฐ/๋น„๋™๊ธฐ ํ˜ผ์šฉ ์‹œ ํ•ด๊ฒฐ์ฑ…:** -```python -# ๋น„๋™๊ธฐ ์—”์ง„์„ ๋™๊ธฐ fixture์—์„œ ์‚ฌ์šฉํ•˜๋ ค๋ฉด -@pytest.fixture -def fastapi_app(db_engine: AsyncEngine): # ๋™๊ธฐ fixture๊ฐ€ ๋น„๋™๊ธฐ ์—”์ง„ ์˜์กด - app = FastAPI() - - # ๋‚ด๋ถ€์—์„œ ๋น„๋™๊ธฐ ํ•จ์ˆ˜ ์ •์˜ (ํด๋กœ์ €) - async def override_use_session(): - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - - app.dependency_overrides[use_session] = override_use_session - return app -``` - ---- - -### ๐Ÿš€ ์‹ค์ œ ํ”„๋กœ์ ํŠธ Fixture ๊ตฌ์กฐ - -์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ `tests/conftest.py`: - -```python -# 1. ์ตœ์ƒ์œ„: DB ์—”์ง„ (๋ชจ๋“ˆ๋‹น 1๋ฒˆ ์ƒ์„ฑ) -@pytest.fixture(scope="function") -async def db_engine(): - """์ธ๋ฉ”๋ชจ๋ฆฌ SQLite ์—”์ง„ ์ƒ์„ฑ""" - dsn = "sqlite+aiosqlite:///:memory:" - engine = create_async_engine(dsn) - - # ํ…Œ์ด๋ธ” ์ƒ์„ฑ - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - - yield engine # ์—”์ง„ ์ œ๊ณต - - # ์ •๋ฆฌ: ํ…Œ์ด๋ธ” ์‚ญ์ œ, ์—”์ง„ ์ข…๋ฃŒ - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.drop_all) - await engine.dispose() - - -# 2. DB ์„ธ์…˜ (๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ์šฉ) -@pytest.fixture(scope="function") -async def db_session(db_engine): - """๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋…๋ฆฝ์ ์ธ ์„ธ์…˜ ์ œ๊ณต""" - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - await session.rollback() # ํ…Œ์ŠคํŠธ ํ›„ ๋กค๋ฐฑ - - -# 3. FastAPI ์•ฑ (HTTP ํ…Œ์ŠคํŠธ์šฉ) -@pytest.fixture() -def fastapi_app(db_engine): - """ํ…Œ์ŠคํŠธ์šฉ FastAPI ์•ฑ ์ƒ์„ฑ""" - app = FastAPI() - include_routers(app) - - # ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ: ์‹ค์ œ DB ๋Œ€์‹  ํ…Œ์ŠคํŠธ DB ์‚ฌ์šฉ - async def override_use_session(): - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - - app.dependency_overrides[use_session] = override_use_session - return app - - -# 4. HTTP ํด๋ผ์ด์–ธํŠธ -@pytest.fixture() -def client(fastapi_app): - """ํ…Œ์ŠคํŠธ์šฉ HTTP ํด๋ผ์ด์–ธํŠธ""" - with TestClient(fastapi_app) as client: - yield client -``` - ---- - -### ๐Ÿ”„ ์‹คํ–‰ ํ๋ฆ„ ์˜ˆ์‹œ - -```python -async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): - # 1. ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ค€๋น„ - user = User(username="test") - db_session.add(user) - await db_session.commit() - - # 2. HTTP API ํ˜ธ์ถœ - response = client.get(f"/account/users/{user.username}") - assert response.status_code == 200 -``` - -**์‹คํ–‰ ์ˆœ์„œ:** - -``` -1. db_engine fixture ์‹คํ–‰ - โ”œโ”€ ์—”์ง„ ์ƒ์„ฑ - โ””โ”€ ํ…Œ์ด๋ธ” ์ƒ์„ฑ (users, calendars ๋“ฑ) - -2. db_session fixture ์‹คํ–‰ - โ”œโ”€ db_engine์„ ๋ฐ›์•„์„œ ์„ธ์…˜ ์ƒ์„ฑ - โ””โ”€ ํ…Œ์ŠคํŠธ์— ์„ธ์…˜ ์ œ๊ณต - -3. fastapi_app fixture ์‹คํ–‰ - โ”œโ”€ db_engine์„ ๋ฐ›์•„์„œ ์•ฑ ์ƒ์„ฑ - โ””โ”€ ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ ์„ค์ • - -4. client fixture ์‹คํ–‰ - โ”œโ”€ fastapi_app์„ ๋ฐ›์•„์„œ TestClient ์ƒ์„ฑ - โ””โ”€ ํ…Œ์ŠคํŠธ์— client ์ œ๊ณต - -5. ํ…Œ์ŠคํŠธ ์‹คํ–‰ - โ”œโ”€ db_session์œผ๋กœ ์‚ฌ์šฉ์ž ์ƒ์„ฑ - โ””โ”€ client๋กœ API ํ˜ธ์ถœ - -6. Teardown (์—ญ์ˆœ์œผ๋กœ ์ •๋ฆฌ) - โ”œโ”€ client ์ •๋ฆฌ - โ”œโ”€ fastapi_app ์ •๋ฆฌ - โ”œโ”€ db_session ๋กค๋ฐฑ - โ””โ”€ db_engine ์ •๋ฆฌ (ํ…Œ์ด๋ธ” ์‚ญ์ œ, ์—”์ง„ ์ข…๋ฃŒ) -``` - ---- - -### ๐Ÿ’ก ํ•ต์‹ฌ ๊ฐœ๋… ์ •๋ฆฌ - -1. **Fixture = ํ…Œ์ŠคํŠธ ์žฌ๋ฃŒ ์ค€๋น„ + ์ž๋™ ์ •๋ฆฌ** - - `yield` ์•ž: Setup - - `yield` ๊ฐ’: ํ…Œ์ŠคํŠธ์— ์ „๋‹ฌ - - `yield` ๋’ค: Teardown - -2. **์˜์กด์„ฑ ์ฃผ์ž…** - - ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ์— fixture ์ด๋ฆ„ ์ž‘์„ฑ - - pytest๊ฐ€ ์ž๋™์œผ๋กœ fixture ์‹คํ–‰ ํ›„ ๊ฒฐ๊ณผ ์ „๋‹ฌ - -3. **Fixture ์ฒด์ธ** - - Fixture๊ฐ€ ๋‹ค๋ฅธ fixture๋ฅผ ์‚ฌ์šฉ ๊ฐ€๋Šฅ - - ์˜์กด์„ฑ ์ˆœ์„œ๋Œ€๋กœ ์ž๋™ ์‹คํ–‰ - -4. **Scope๋กœ ์ƒ๋ช…์ฃผ๊ธฐ ์ œ์–ด** - - `function`: ๋งค ํ…Œ์ŠคํŠธ๋งˆ๋‹ค (๊ธฐ๋ณธ๊ฐ’) - - `module`: ํŒŒ์ผ๋‹น 1๋ฒˆ - - `session`: ์ „์ฒด ํ…Œ์ŠคํŠธ๋‹น 1๋ฒˆ - -5. **`conftest.py`** - - ์—ฌ๋Ÿฌ ํ…Œ์ŠคํŠธ ํŒŒ์ผ์—์„œ ๊ณต์œ ํ•  fixture ์ •์˜ - - pytest๊ฐ€ ์ž๋™์œผ๋กœ ์ธ์‹ - ---- - -### ๐ŸŽ“ ํ•™์Šต ํŒ - -1. **๊ฐ„๋‹จํ•œ fixture๋ถ€ํ„ฐ ์‹œ์ž‘** - ```python - @pytest.fixture - def simple_data(): - return [1, 2, 3] - ``` - -2. **print๋กœ ์‹คํ–‰ ์ˆœ์„œ ํ™•์ธ** - ```python - @pytest.fixture - def my_fixture(): - print("Setup!") - yield "data" - print("Teardown!") - ``` - -3. **์‹ค์ œ ์‚ฌ์šฉ ์‚ฌ๋ก€ ์—ฐ์Šต** - - ํŒŒ์ผ ์ƒ์„ฑ/์‚ญ์ œ - - DB ์—ฐ๊ฒฐ/์ข…๋ฃŒ - - API ์„œ๋ฒ„ ์‹œ์ž‘/์ข…๋ฃŒ - -4. **๊ณต์‹ ๋ฌธ์„œ ์ฐธ๊ณ ** - - https://docs.pytest.org/en/stable/fixture.html - ---- - -## ๐Ÿ”€ Fixture vs ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ - -### โšก ํ•ต์‹ฌ ์ฐจ์ด์  - -| ๊ตฌ๋ถ„ | ํ…Œ์ŠคํŠธ ์ฝ”๋“œ | ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ | -|------|------------|--------------| -| **์‚ฌ์šฉ ๊ธฐ์ˆ ** | Pytest Fixture | FastAPI Dependency Injection | -| **์‚ฌ์šฉ ์œ„์น˜** | `tests/` ๋””๋ ‰ํ† ๋ฆฌ | `appserver/` ๋””๋ ‰ํ† ๋ฆฌ | -| **๋ชฉ์ ** | ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ์ค€๋น„ | ์‹ค์ œ ์„œ๋น„์Šค ๋กœ์ง | -| **DB** | ์ธ๋ฉ”๋ชจ๋ฆฌ SQLite | ์‹ค์ œ DB (PostgreSQL ๋“ฑ) | -| **์ƒ๋ช…์ฃผ๊ธฐ** | ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ์ƒ์„ฑ/์‚ญ์ œ | ์•ฑ ์‹คํ–‰ ์ค‘ ์œ ์ง€ | - ---- - -### ๐Ÿ“ ๋น„๊ต ์˜ˆ์‹œ - -#### 1๏ธโƒฃ **ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ DB ์„ธ์…˜ ์‚ฌ์šฉ** (Pytest Fixture) - -```python -# tests/conftest.py -@pytest.fixture -async def db_session(db_engine): - """ํ…Œ์ŠคํŠธ์šฉ DB ์„ธ์…˜ - ์ธ๋ฉ”๋ชจ๋ฆฌ SQLite ์‚ฌ์šฉ""" - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - await session.rollback() # ํ…Œ์ŠคํŠธ ํ›„ ๋กค๋ฐฑ - -# tests/test_user.py -async def test_create_user(db_session: AsyncSession): - """Fixture๋กœ ์„ธ์…˜ ์ฃผ์ž…๋ฐ›์Œ""" - user = User(username="test") - db_session.add(user) - await db_session.commit() -``` - ---- - -#### 2๏ธโƒฃ **ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ์—์„œ DB ์„ธ์…˜ ์‚ฌ์šฉ** (FastAPI Dependency) - -```python -# appserver/db.py -async def use_session(): - """์‹ค์ œ ์„œ๋น„์Šค์šฉ DB ์„ธ์…˜ - ์‹ค์ œ DB ์‚ฌ์šฉ""" - async with session_factory() as session: - yield session - -# appserver/apps/account/endpoints.py -@router.get("/users/{username}") -async def user_detail( - username: str, - session: AsyncSession = Depends(use_session) # FastAPI ์˜์กด์„ฑ ์ฃผ์ž… -): - """FastAPI Depends๋กœ ์„ธ์…˜ ์ฃผ์ž…๋ฐ›์Œ""" - user = await session.get(User, username) - if not user: - raise HTTPException(status_code=404) - return user -``` - ---- - -### ๐ŸŽฏ ์™œ ํ…Œ์ŠคํŠธ์—์„œ๋Š” Fixture๋ฅผ ์‚ฌ์šฉํ•˜๋‚˜? - -1. **๊ฒฉ๋ฆฌ๋œ ํ™˜๊ฒฝ** - - ์‹ค์ œ DB๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Œ - - ์ธ๋ฉ”๋ชจ๋ฆฌ DB๋กœ ๋น ๋ฅธ ํ…Œ์ŠคํŠธ - - ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๊นจ๋—ํ•œ ์ƒํƒœ - -2. **์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ** - ```python - # ํ”„๋กœ๋•์…˜: ์‹ค์ œ DB ์‚ฌ์šฉ - app.dependency_overrides[use_session] = ์‹ค์ œ_์„ธ์…˜ - - # ํ…Œ์ŠคํŠธ: ํ…Œ์ŠคํŠธ DB ์‚ฌ์šฉ - app.dependency_overrides[use_session] = ํ…Œ์ŠคํŠธ_์„ธ์…˜ - ``` - -3. **์ž๋™ ์ •๋ฆฌ** - - ํ…Œ์ŠคํŠธ ํ›„ ์ž๋™์œผ๋กœ ๋ฐ์ดํ„ฐ ์‚ญ์ œ - - ๋ฆฌ์†Œ์Šค ๋ˆ„์ˆ˜ ๋ฐฉ์ง€ - ---- - -### ๐Ÿ“‚ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ๋กœ ์ดํ•ดํ•˜๊ธฐ - -``` -fastapi-project-study/ -โ”œโ”€โ”€ appserver/ # ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ -โ”‚ โ”œโ”€โ”€ db.py # use_session (FastAPI Depends) -โ”‚ โ””โ”€โ”€ apps/ -โ”‚ โ””โ”€โ”€ account/ -โ”‚ โ””โ”€โ”€ endpoints.py # Depends(use_session) ์‚ฌ์šฉ -โ”‚ -โ””โ”€โ”€ tests/ # ํ…Œ์ŠคํŠธ ์ฝ”๋“œ - โ”œโ”€โ”€ conftest.py # db_session (Pytest Fixture) - โ””โ”€โ”€ apps/ - โ””โ”€โ”€ account/ - โ””โ”€โ”€ test_endpoints.py # db_session fixture ์‚ฌ์šฉ -``` - ---- - -### ๐Ÿ’ก ์ •๋ฆฌ - -- **Pytest Fixture** = ํ…Œ์ŠคํŠธ ์ „์šฉ ๋„๊ตฌ - - `tests/` ๋””๋ ‰ํ† ๋ฆฌ์—์„œ๋งŒ ์‚ฌ์šฉ - - `@pytest.fixture` ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ - - ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ์ค€๋น„ ๋ฐ ์ •๋ฆฌ - -- **FastAPI Dependency** = ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ์˜ ์˜์กด์„ฑ ์ฃผ์ž… - - `appserver/` ๋””๋ ‰ํ† ๋ฆฌ์—์„œ ์‚ฌ์šฉ - - `Depends()` ํ•จ์ˆ˜ - - ์‹ค์ œ ์„œ๋น„์Šค ๋กœ์ง - +# fastapi-project-study + +## ๐Ÿ“š Pytest Fixture ์™„๋ฒฝ ๊ฐ€์ด๋“œ + +### ๐Ÿค” Fixture๋ž€? + +**Fixture**๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ ์ „์— ํ•„์š”ํ•œ **์ค€๋น„ ์ž‘์—…(setup)**๊ณผ ํ…Œ์ŠคํŠธ ํ›„ **์ •๋ฆฌ ์ž‘์—…(teardown)**์„ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ด์ฃผ๋Š” **pytest์˜ ํ…Œ์ŠคํŠธ ์ „์šฉ ๊ธฐ๋Šฅ**์ž…๋‹ˆ๋‹ค. + +์‰ฝ๊ฒŒ ๋งํ•˜๋ฉด: +- ํ…Œ์ŠคํŠธ์— ํ•„์š”ํ•œ **์žฌ๋ฃŒ(๋ฐ์ดํ„ฐ, ๊ฐ์ฒด, ์—ฐ๊ฒฐ ๋“ฑ)**๋ฅผ ๋ฏธ๋ฆฌ ์ค€๋น„ํ•ด์ฃผ๋Š” ํ•จ์ˆ˜ +- ํ…Œ์ŠคํŠธ๊ฐ€ ๋๋‚˜๋ฉด ์ž๋™์œผ๋กœ **์ •๋ฆฌ**๊นŒ์ง€ ํ•ด์คŒ +- ์—ฌ๋Ÿฌ ํ…Œ์ŠคํŠธ์—์„œ **์žฌ์‚ฌ์šฉ** ๊ฐ€๋Šฅ + +> โš ๏ธ **์ค‘์š”**: Fixture๋Š” **ํ…Œ์ŠคํŠธ ์ฝ”๋“œ(`tests/` ๋””๋ ‰ํ† ๋ฆฌ)์—์„œ๋งŒ** ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ์‹ค์ œ ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ(`appserver/`)์—์„œ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค! + +--- + +### ๐ŸŽฏ ์™œ Fixture๋ฅผ ์‚ฌ์šฉํ•˜๋‚˜? + +#### โŒ Fixture ์—†์ด ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๋ฉด: + +```python +def test_user_creation(): + # ๋งค๋ฒˆ DB ์—ฐ๊ฒฐ ์„ค์ • + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + session = Session(engine) + + # ์‹ค์ œ ํ…Œ์ŠคํŠธ + user = User(username="test") + session.add(user) + session.commit() + + # ์ •๋ฆฌ + session.close() + engine.dispose() + +def test_user_query(): + # ๋˜ ๋˜‘๊ฐ™์ด DB ์—ฐ๊ฒฐ ์„ค์ • (์ค‘๋ณต!) + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + session = Session(engine) + + # ์‹ค์ œ ํ…Œ์ŠคํŠธ + user = session.query(User).first() + + # ์ •๋ฆฌ + session.close() + engine.dispose() +``` + +**๋ฌธ์ œ์ :** +- ์ฝ”๋“œ ์ค‘๋ณต์ด ์‹ฌํ•จ +- ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ๊ธธ๊ณ  ๋ณต์žกํ•จ +- ์ •๋ฆฌ ์ž‘์—…์„ ๊นœ๋นกํ•˜๋ฉด ๋ฆฌ์†Œ์Šค ๋ˆ„์ˆ˜ ๋ฐœ์ƒ + +--- + +#### โœ… Fixture ์‚ฌ์šฉํ•˜๋ฉด: + +```python +@pytest.fixture +def db_session(): + # Setup: ์ค€๋น„ ์ž‘์—… + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + session = Session(engine) + + yield session # ํ…Œ์ŠคํŠธ์— session ์ œ๊ณต + + # Teardown: ์ •๋ฆฌ ์ž‘์—… (์ž๋™ ์‹คํ–‰) + session.close() + engine.dispose() + +# ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜์—์„œ fixture ์ด๋ฆ„์„ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์œผ๋ฉด ์ž๋™ ์ฃผ์ž…! +def test_user_creation(db_session): + user = User(username="test") + db_session.add(user) + db_session.commit() + # ์ •๋ฆฌ๋Š” ์ž๋™์œผ๋กœ ๋จ! + +def test_user_query(db_session): + user = db_session.query(User).first() + # ์ •๋ฆฌ๋Š” ์ž๋™์œผ๋กœ ๋จ! +``` + +**์žฅ์ :** +- ์ฝ”๋“œ ์ค‘๋ณต ์ œ๊ฑฐ +- ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ๊ฐ„๊ฒฐํ•ด์ง +- ์ •๋ฆฌ ์ž‘์—… ์ž๋™ํ™” (๋ฆฌ์†Œ์Šค ๋ˆ„์ˆ˜ ๋ฐฉ์ง€) + +--- + +### ๐Ÿ”ง Fixture ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ• + +#### 1๏ธโƒฃ **๊ธฐ๋ณธ Fixture ์ •์˜** + +```python +import pytest + +@pytest.fixture +def sample_data(): + """๊ฐ„๋‹จํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ณตํ•˜๋Š” fixture""" + return {"name": "Alice", "age": 30} + +def test_sample(sample_data): + # sample_data fixture๊ฐ€ ์ž๋™์œผ๋กœ ์ฃผ์ž…๋จ + assert sample_data["name"] == "Alice" + assert sample_data["age"] == 30 +``` + +**์ž‘๋™ ๋ฐฉ์‹:** +1. pytest๊ฐ€ `test_sample` ํ•จ์ˆ˜๋ฅผ ๋ฐœ๊ฒฌ +2. ํŒŒ๋ผ๋ฏธํ„ฐ `sample_data`๋ฅผ ํ™•์ธ +3. ๊ฐ™์€ ์ด๋ฆ„์˜ fixture๋ฅผ ์ฐพ์•„์„œ ์‹คํ–‰ +4. fixture์˜ ๋ฐ˜ํ™˜๊ฐ’์„ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜์— ์ „๋‹ฌ + +--- + +#### 2๏ธโƒฃ **Setup/Teardown์ด ์žˆ๋Š” Fixture** + +```python +@pytest.fixture +def temp_file(): + # Setup: ํŒŒ์ผ ์ƒ์„ฑ + file_path = "test_temp.txt" + with open(file_path, "w") as f: + f.write("test data") + + yield file_path # ํ…Œ์ŠคํŠธ์— ํŒŒ์ผ ๊ฒฝ๋กœ ์ œ๊ณต + + # Teardown: ํŒŒ์ผ ์‚ญ์ œ (ํ…Œ์ŠคํŠธ ํ›„ ์ž๋™ ์‹คํ–‰) + import os + os.remove(file_path) + +def test_file_read(temp_file): + with open(temp_file, "r") as f: + content = f.read() + assert content == "test data" + # ํ…Œ์ŠคํŠธ ๋๋‚˜๋ฉด ํŒŒ์ผ์ด ์ž๋™์œผ๋กœ ์‚ญ์ œ๋จ! +``` + +**`yield` ํ‚ค์›Œ๋“œ:** +- `yield` ์•ž: Setup (ํ…Œ์ŠคํŠธ ์ „ ์‹คํ–‰) +- `yield` ๊ฐ’: ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜์— ์ „๋‹ฌ +- `yield` ๋’ค: Teardown (ํ…Œ์ŠคํŠธ ํ›„ ์‹คํ–‰) + +--- + +#### 3๏ธโƒฃ **Fixture๊ฐ€ ๋‹ค๋ฅธ Fixture๋ฅผ ์‚ฌ์šฉ** + +```python +@pytest.fixture +def database_engine(): + engine = create_engine("sqlite:///:memory:") + yield engine + engine.dispose() + +@pytest.fixture +def database_session(database_engine): # ๋‹ค๋ฅธ fixture๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ! + session = Session(database_engine) + yield session + session.close() + +def test_with_session(database_session): + # database_engine โ†’ database_session โ†’ test ์ˆœ์„œ๋กœ ์‹คํ–‰ + user = User(username="test") + database_session.add(user) +``` + +**์˜์กด์„ฑ ์ฒด์ธ:** +``` +database_engine (๋จผ์ € ์‹คํ–‰) + โ†“ +database_session (engine์„ ๋ฐ›์•„์„œ ์‹คํ–‰) + โ†“ +test_with_session (session์„ ๋ฐ›์•„์„œ ์‹คํ–‰) +``` + +--- + +### ๐ŸŽจ Fixture Scope (๋ฒ”์œ„) + +Fixture๊ฐ€ **์–ธ์ œ ์ƒ์„ฑ๋˜๊ณ  ์–ธ์ œ ์ •๋ฆฌ๋˜๋Š”์ง€** ์ œ์–ดํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +```python +@pytest.fixture(scope="function") # ๊ธฐ๋ณธ๊ฐ’: ๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ์ƒˆ๋กœ ์ƒ์„ฑ +def function_scope(): + print("Setup") + yield "data" + print("Teardown") + +@pytest.fixture(scope="module") # ๋ชจ๋“ˆ(ํŒŒ์ผ)๋‹น 1๋ฒˆ๋งŒ ์ƒ์„ฑ +def module_scope(): + print("Setup once per module") + yield "data" + print("Teardown once per module") + +@pytest.fixture(scope="session") # ์ „์ฒด ํ…Œ์ŠคํŠธ ์„ธ์…˜๋‹น 1๋ฒˆ๋งŒ ์ƒ์„ฑ +def session_scope(): + print("Setup once per session") + yield "data" + print("Teardown once per session") +``` + +| Scope | ์ƒ์„ฑ ์‹œ์  | ์ •๋ฆฌ ์‹œ์  | ์‚ฌ์šฉ ์˜ˆ์‹œ | +|-------|----------|----------|----------| +| `function` | ๊ฐ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜๋งˆ๋‹ค | ๊ฐ ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | DB ์„ธ์…˜, ์ž„์‹œ ํŒŒ์ผ | +| `class` | ๊ฐ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋งˆ๋‹ค | ํด๋ž˜์Šค ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | ํด๋ž˜์Šค๋ณ„ ์„ค์ • | +| `module` | ๊ฐ ํ…Œ์ŠคํŠธ ํŒŒ์ผ๋งˆ๋‹ค | ํŒŒ์ผ ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | DB ์—ฐ๊ฒฐ ํ’€ | +| `session` | ์ „์ฒด ํ…Œ์ŠคํŠธ ์‹œ์ž‘ ์‹œ | ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ํ›„ | ํ…Œ์ŠคํŠธ ์„œ๋ฒ„ | + +--- + +### ๐ŸŽจ Fixture ๊ณ ๊ธ‰ ์˜ต์…˜ + +#### 1๏ธโƒฃ **`autouse=True` - ์ž๋™ ์‹คํ–‰** + +๋ชจ๋“  ํ…Œ์ŠคํŠธ์—์„œ **์ž๋™์œผ๋กœ ์‹คํ–‰**๋˜์–ด์•ผ ํ•˜๋Š” fixture๊ฐ€ ์žˆ๋‹ค๋ฉด ์ด ์˜ต์…˜์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ช…์‹œํ•˜์ง€ ์•Š์•„๋„ ์ž๋™์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. + +```python +@pytest.fixture(autouse=True) +def setup_log(): + """๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ „์— ์ž๋™์œผ๋กœ ์‹คํ–‰""" + print("\n[LOG] ํ…Œ์ŠคํŠธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค...") + yield + print("[LOG] ํ…Œ์ŠคํŠธ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค...") + +def test_something(): + # setup_log๋ฅผ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์ง€ ์•Š์•„๋„ ์ž๋™ ์‹คํ–‰๋จ! + assert True +``` + +**์‚ฌ์šฉ ์˜ˆ์‹œ:** +- ๋กœ๊น… ์„ค์ • +- ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ดˆ๊ธฐํ™” +- ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” (์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ `db_session`์ฒ˜๋Ÿผ) + +```python +# ์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ ์‹ค์ œ ์˜ˆ์‹œ +@pytest.fixture(autouse=True) # ๋ชจ๋“  ํ…Œ์ŠคํŠธ์—์„œ ์ž๋™์œผ๋กœ DB ์„ธ์…˜ ์ƒ์„ฑ +async def db_session(): + # ... +``` + +--- + +#### 2๏ธโƒฃ **`name` - Fixture ์ด๋ฆ„ ์ง€์ •** + +Fixture ํ•จ์ˆ˜ ์ด๋ฆ„์ด ๋„ˆ๋ฌด ๊ธธ๊ฑฐ๋‚˜, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ ๋” ์ง๊ด€์ ์ธ ์ด๋ฆ„์„ ์“ฐ๊ณ  ์‹ถ์„ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +```python +@pytest.fixture(name="db") +def a_very_long_database_session_fixture_name(): + """ํ•จ์ˆ˜๋ช…์€ ๊ธธ์ง€๋งŒ, 'db'๋ผ๋Š” ์งง์€ ์ด๋ฆ„์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ""" + engine = create_engine("sqlite:///:memory:") + session = Session(engine) + yield session + session.close() + +def test_user(db): # ํ•จ์ˆ˜๋ช… ๋Œ€์‹  'db'๋ผ๋Š” ์ด๋ฆ„์œผ๋กœ ์ฃผ์ž…! + user = User(username="test") + db.add(user) + assert db is not None +``` + +**์–ธ์ œ ์‚ฌ์šฉํ•˜๋‚˜:** +- ํ•จ์ˆ˜๋ช…์ด ๋„ˆ๋ฌด ๊ธธ ๋•Œ +- ๋” ์ง๊ด€์ ์ธ ์ด๋ฆ„์„ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์„ ๋•Œ +- ๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด + +--- + +#### 3๏ธโƒฃ **๋น„๋™๊ธฐ Fixture ์ฃผ์˜์‚ฌํ•ญ** + +FastAPI๋Š” ๋น„๋™๊ธฐ(`async/await`)๋ฅผ ๋งŽ์ด ์‚ฌ์šฉํ•˜๋ฏ€๋กœ, Fixture์—์„œ๋„ ๋น„๋™๊ธฐ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ์ฃผ์˜๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. + +**ํ•„์ˆ˜ ์„ค์ •:** + +1. **`pytest-asyncio` ์„ค์น˜** + ```bash + pip install pytest-asyncio + ``` + +2. **`pyproject.toml` ์„ค์ •** + ```toml + [tool.pytest.ini_options] + asyncio_mode = "auto" # ๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ ์ž๋™ ๊ฐ์ง€ + ``` + +3. **๋น„๋™๊ธฐ Fixture ์ž‘์„ฑ** + ```python + @pytest.fixture + async def async_client(): + """๋น„๋™๊ธฐ fixture๋Š” async def๋กœ ์ •์˜""" + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + + async def test_api(async_client): + """๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜๋„ async def๋กœ ์ •์˜""" + response = await async_client.get("/users/test") + assert response.status_code == 200 + ``` + +**์ฃผ์˜์‚ฌํ•ญ:** + +| ๊ตฌ๋ถ„ | ๋™๊ธฐ Fixture | ๋น„๋™๊ธฐ Fixture | +|------|-------------|---------------| +| ์ •์˜ | `def fixture()` | `async def fixture()` | +| ์‚ฌ์šฉ | ๋™๊ธฐ ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ | ๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉ | +| ์˜์กด์„ฑ | ๋™๊ธฐ fixture๋งŒ ์˜์กด ๊ฐ€๋Šฅ | ๋น„๋™๊ธฐ/๋™๊ธฐ ๋ชจ๋‘ ์˜์กด ๊ฐ€๋Šฅ | + +**์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ ์˜ˆ์‹œ:** +```python +# โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ +@pytest.fixture +async def db_session(db_engine): # ๋น„๋™๊ธฐ fixture + async with session_factory() as session: + yield session + +async def test_user(db_session): # ๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ + user = User(username="test") + db_session.add(user) + await db_session.commit() # await ์‚ฌ์šฉ + +# โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ +def test_user(db_session): # ๋™๊ธฐ ํ…Œ์ŠคํŠธ์—์„œ ๋น„๋™๊ธฐ fixture ์‚ฌ์šฉ ๋ถˆ๊ฐ€ + await db_session.commit() # SyntaxError! +``` + +**๋™๊ธฐ/๋น„๋™๊ธฐ ํ˜ผ์šฉ ์‹œ ํ•ด๊ฒฐ์ฑ…:** +```python +# ๋น„๋™๊ธฐ ์—”์ง„์„ ๋™๊ธฐ fixture์—์„œ ์‚ฌ์šฉํ•˜๋ ค๋ฉด +@pytest.fixture +def fastapi_app(db_engine: AsyncEngine): # ๋™๊ธฐ fixture๊ฐ€ ๋น„๋™๊ธฐ ์—”์ง„ ์˜์กด + app = FastAPI() + + # ๋‚ด๋ถ€์—์„œ ๋น„๋™๊ธฐ ํ•จ์ˆ˜ ์ •์˜ (ํด๋กœ์ €) + async def override_use_session(): + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + + app.dependency_overrides[use_session] = override_use_session + return app +``` + +--- + +### ๐Ÿš€ ์‹ค์ œ ํ”„๋กœ์ ํŠธ Fixture ๊ตฌ์กฐ + +์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ `tests/conftest.py`: + +```python +# 1. ์ตœ์ƒ์œ„: DB ์—”์ง„ (๋ชจ๋“ˆ๋‹น 1๋ฒˆ ์ƒ์„ฑ) +@pytest.fixture(scope="function") +async def db_engine(): + """์ธ๋ฉ”๋ชจ๋ฆฌ SQLite ์—”์ง„ ์ƒ์„ฑ""" + dsn = "sqlite+aiosqlite:///:memory:" + engine = create_async_engine(dsn) + + # ํ…Œ์ด๋ธ” ์ƒ์„ฑ + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + yield engine # ์—”์ง„ ์ œ๊ณต + + # ์ •๋ฆฌ: ํ…Œ์ด๋ธ” ์‚ญ์ œ, ์—”์ง„ ์ข…๋ฃŒ + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await engine.dispose() + + +# 2. DB ์„ธ์…˜ (๋น„๋™๊ธฐ ํ…Œ์ŠคํŠธ์šฉ) +@pytest.fixture(scope="function") +async def db_session(db_engine): + """๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋…๋ฆฝ์ ์ธ ์„ธ์…˜ ์ œ๊ณต""" + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + await session.rollback() # ํ…Œ์ŠคํŠธ ํ›„ ๋กค๋ฐฑ + + +# 3. FastAPI ์•ฑ (HTTP ํ…Œ์ŠคํŠธ์šฉ) +@pytest.fixture() +def fastapi_app(db_engine): + """ํ…Œ์ŠคํŠธ์šฉ FastAPI ์•ฑ ์ƒ์„ฑ""" + app = FastAPI() + include_routers(app) + + # ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ: ์‹ค์ œ DB ๋Œ€์‹  ํ…Œ์ŠคํŠธ DB ์‚ฌ์šฉ + async def override_use_session(): + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + + app.dependency_overrides[use_session] = override_use_session + return app + + +# 4. HTTP ํด๋ผ์ด์–ธํŠธ +@pytest.fixture() +def client(fastapi_app): + """ํ…Œ์ŠคํŠธ์šฉ HTTP ํด๋ผ์ด์–ธํŠธ""" + with TestClient(fastapi_app) as client: + yield client +``` + +--- + +### ๐Ÿ”„ ์‹คํ–‰ ํ๋ฆ„ ์˜ˆ์‹œ + +```python +async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): + # 1. ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ค€๋น„ + user = User(username="test") + db_session.add(user) + await db_session.commit() + + # 2. HTTP API ํ˜ธ์ถœ + response = client.get(f"/account/users/{user.username}") + assert response.status_code == 200 +``` + +**์‹คํ–‰ ์ˆœ์„œ:** + +``` +1. db_engine fixture ์‹คํ–‰ + โ”œโ”€ ์—”์ง„ ์ƒ์„ฑ + โ””โ”€ ํ…Œ์ด๋ธ” ์ƒ์„ฑ (users, calendars ๋“ฑ) + +2. db_session fixture ์‹คํ–‰ + โ”œโ”€ db_engine์„ ๋ฐ›์•„์„œ ์„ธ์…˜ ์ƒ์„ฑ + โ””โ”€ ํ…Œ์ŠคํŠธ์— ์„ธ์…˜ ์ œ๊ณต + +3. fastapi_app fixture ์‹คํ–‰ + โ”œโ”€ db_engine์„ ๋ฐ›์•„์„œ ์•ฑ ์ƒ์„ฑ + โ””โ”€ ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ ์„ค์ • + +4. client fixture ์‹คํ–‰ + โ”œโ”€ fastapi_app์„ ๋ฐ›์•„์„œ TestClient ์ƒ์„ฑ + โ””โ”€ ํ…Œ์ŠคํŠธ์— client ์ œ๊ณต + +5. ํ…Œ์ŠคํŠธ ์‹คํ–‰ + โ”œโ”€ db_session์œผ๋กœ ์‚ฌ์šฉ์ž ์ƒ์„ฑ + โ””โ”€ client๋กœ API ํ˜ธ์ถœ + +6. Teardown (์—ญ์ˆœ์œผ๋กœ ์ •๋ฆฌ) + โ”œโ”€ client ์ •๋ฆฌ + โ”œโ”€ fastapi_app ์ •๋ฆฌ + โ”œโ”€ db_session ๋กค๋ฐฑ + โ””โ”€ db_engine ์ •๋ฆฌ (ํ…Œ์ด๋ธ” ์‚ญ์ œ, ์—”์ง„ ์ข…๋ฃŒ) +``` + +--- + +### ๐Ÿ’ก ํ•ต์‹ฌ ๊ฐœ๋… ์ •๋ฆฌ + +1. **Fixture = ํ…Œ์ŠคํŠธ ์žฌ๋ฃŒ ์ค€๋น„ + ์ž๋™ ์ •๋ฆฌ** + - `yield` ์•ž: Setup + - `yield` ๊ฐ’: ํ…Œ์ŠคํŠธ์— ์ „๋‹ฌ + - `yield` ๋’ค: Teardown + +2. **์˜์กด์„ฑ ์ฃผ์ž…** + - ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ์— fixture ์ด๋ฆ„ ์ž‘์„ฑ + - pytest๊ฐ€ ์ž๋™์œผ๋กœ fixture ์‹คํ–‰ ํ›„ ๊ฒฐ๊ณผ ์ „๋‹ฌ + +3. **Fixture ์ฒด์ธ** + - Fixture๊ฐ€ ๋‹ค๋ฅธ fixture๋ฅผ ์‚ฌ์šฉ ๊ฐ€๋Šฅ + - ์˜์กด์„ฑ ์ˆœ์„œ๋Œ€๋กœ ์ž๋™ ์‹คํ–‰ + +4. **Scope๋กœ ์ƒ๋ช…์ฃผ๊ธฐ ์ œ์–ด** + - `function`: ๋งค ํ…Œ์ŠคํŠธ๋งˆ๋‹ค (๊ธฐ๋ณธ๊ฐ’) + - `module`: ํŒŒ์ผ๋‹น 1๋ฒˆ + - `session`: ์ „์ฒด ํ…Œ์ŠคํŠธ๋‹น 1๋ฒˆ + +5. **`conftest.py`** + - ์—ฌ๋Ÿฌ ํ…Œ์ŠคํŠธ ํŒŒ์ผ์—์„œ ๊ณต์œ ํ•  fixture ์ •์˜ + - pytest๊ฐ€ ์ž๋™์œผ๋กœ ์ธ์‹ + +--- + +### ๐ŸŽ“ ํ•™์Šต ํŒ + +1. **๊ฐ„๋‹จํ•œ fixture๋ถ€ํ„ฐ ์‹œ์ž‘** + ```python + @pytest.fixture + def simple_data(): + return [1, 2, 3] + ``` + +2. **print๋กœ ์‹คํ–‰ ์ˆœ์„œ ํ™•์ธ** + ```python + @pytest.fixture + def my_fixture(): + print("Setup!") + yield "data" + print("Teardown!") + ``` + +3. **์‹ค์ œ ์‚ฌ์šฉ ์‚ฌ๋ก€ ์—ฐ์Šต** + - ํŒŒ์ผ ์ƒ์„ฑ/์‚ญ์ œ + - DB ์—ฐ๊ฒฐ/์ข…๋ฃŒ + - API ์„œ๋ฒ„ ์‹œ์ž‘/์ข…๋ฃŒ + +4. **๊ณต์‹ ๋ฌธ์„œ ์ฐธ๊ณ ** + - https://docs.pytest.org/en/stable/fixture.html + +--- + +## ๐Ÿ”€ Fixture vs ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ + +### โšก ํ•ต์‹ฌ ์ฐจ์ด์  + +| ๊ตฌ๋ถ„ | ํ…Œ์ŠคํŠธ ์ฝ”๋“œ | ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ | +|------|------------|--------------| +| **์‚ฌ์šฉ ๊ธฐ์ˆ ** | Pytest Fixture | FastAPI Dependency Injection | +| **์‚ฌ์šฉ ์œ„์น˜** | `tests/` ๋””๋ ‰ํ† ๋ฆฌ | `appserver/` ๋””๋ ‰ํ† ๋ฆฌ | +| **๋ชฉ์ ** | ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ์ค€๋น„ | ์‹ค์ œ ์„œ๋น„์Šค ๋กœ์ง | +| **DB** | ์ธ๋ฉ”๋ชจ๋ฆฌ SQLite | ์‹ค์ œ DB (PostgreSQL ๋“ฑ) | +| **์ƒ๋ช…์ฃผ๊ธฐ** | ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ์ƒ์„ฑ/์‚ญ์ œ | ์•ฑ ์‹คํ–‰ ์ค‘ ์œ ์ง€ | + +--- + +### ๐Ÿ“ ๋น„๊ต ์˜ˆ์‹œ + +#### 1๏ธโƒฃ **ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ DB ์„ธ์…˜ ์‚ฌ์šฉ** (Pytest Fixture) + +```python +# tests/conftest.py +@pytest.fixture +async def db_session(db_engine): + """ํ…Œ์ŠคํŠธ์šฉ DB ์„ธ์…˜ - ์ธ๋ฉ”๋ชจ๋ฆฌ SQLite ์‚ฌ์šฉ""" + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + await session.rollback() # ํ…Œ์ŠคํŠธ ํ›„ ๋กค๋ฐฑ + +# tests/test_user.py +async def test_create_user(db_session: AsyncSession): + """Fixture๋กœ ์„ธ์…˜ ์ฃผ์ž…๋ฐ›์Œ""" + user = User(username="test") + db_session.add(user) + await db_session.commit() +``` + +--- + +#### 2๏ธโƒฃ **ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ์—์„œ DB ์„ธ์…˜ ์‚ฌ์šฉ** (FastAPI Dependency) + +```python +# appserver/db.py +async def use_session(): + """์‹ค์ œ ์„œ๋น„์Šค์šฉ DB ์„ธ์…˜ - ์‹ค์ œ DB ์‚ฌ์šฉ""" + async with session_factory() as session: + yield session + +# appserver/apps/account/endpoints.py +@router.get("/users/{username}") +async def user_detail( + username: str, + session: AsyncSession = Depends(use_session) # FastAPI ์˜์กด์„ฑ ์ฃผ์ž… +): + """FastAPI Depends๋กœ ์„ธ์…˜ ์ฃผ์ž…๋ฐ›์Œ""" + user = await session.get(User, username) + if not user: + raise HTTPException(status_code=404) + return user +``` + +--- + +### ๐ŸŽฏ ์™œ ํ…Œ์ŠคํŠธ์—์„œ๋Š” Fixture๋ฅผ ์‚ฌ์šฉํ•˜๋‚˜? + +1. **๊ฒฉ๋ฆฌ๋œ ํ™˜๊ฒฝ** + - ์‹ค์ œ DB๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Œ + - ์ธ๋ฉ”๋ชจ๋ฆฌ DB๋กœ ๋น ๋ฅธ ํ…Œ์ŠคํŠธ + - ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๊นจ๋—ํ•œ ์ƒํƒœ + +2. **์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ** + ```python + # ํ”„๋กœ๋•์…˜: ์‹ค์ œ DB ์‚ฌ์šฉ + app.dependency_overrides[use_session] = ์‹ค์ œ_์„ธ์…˜ + + # ํ…Œ์ŠคํŠธ: ํ…Œ์ŠคํŠธ DB ์‚ฌ์šฉ + app.dependency_overrides[use_session] = ํ…Œ์ŠคํŠธ_์„ธ์…˜ + ``` + +3. **์ž๋™ ์ •๋ฆฌ** + - ํ…Œ์ŠคํŠธ ํ›„ ์ž๋™์œผ๋กœ ๋ฐ์ดํ„ฐ ์‚ญ์ œ + - ๋ฆฌ์†Œ์Šค ๋ˆ„์ˆ˜ ๋ฐฉ์ง€ + +--- + +### ๐Ÿ“‚ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ๋กœ ์ดํ•ดํ•˜๊ธฐ + +``` +fastapi-project-study/ +โ”œโ”€โ”€ appserver/ # ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ +โ”‚ โ”œโ”€โ”€ db.py # use_session (FastAPI Depends) +โ”‚ โ””โ”€โ”€ apps/ +โ”‚ โ””โ”€โ”€ account/ +โ”‚ โ””โ”€โ”€ endpoints.py # Depends(use_session) ์‚ฌ์šฉ +โ”‚ +โ””โ”€โ”€ tests/ # ํ…Œ์ŠคํŠธ ์ฝ”๋“œ + โ”œโ”€โ”€ conftest.py # db_session (Pytest Fixture) + โ””โ”€โ”€ apps/ + โ””โ”€โ”€ account/ + โ””โ”€โ”€ test_endpoints.py # db_session fixture ์‚ฌ์šฉ +``` + +--- + +### ๐Ÿ’ก ์ •๋ฆฌ + +- **Pytest Fixture** = ํ…Œ์ŠคํŠธ ์ „์šฉ ๋„๊ตฌ + - `tests/` ๋””๋ ‰ํ† ๋ฆฌ์—์„œ๋งŒ ์‚ฌ์šฉ + - `@pytest.fixture` ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ + - ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ์ค€๋น„ ๋ฐ ์ •๋ฆฌ + +- **FastAPI Dependency** = ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ์˜ ์˜์กด์„ฑ ์ฃผ์ž… + - `appserver/` ๋””๋ ‰ํ† ๋ฆฌ์—์„œ ์‚ฌ์šฉ + - `Depends()` ํ•จ์ˆ˜ + - ์‹ค์ œ ์„œ๋น„์Šค ๋กœ์ง + **๋‘˜ ๋‹ค "์˜์กด์„ฑ ์ฃผ์ž…" ๊ฐœ๋…์„ ์‚ฌ์šฉํ•˜์ง€๋งŒ, ๋ชฉ์ ๊ณผ ์‚ฌ์šฉ ์œ„์น˜๊ฐ€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค!** ๐ŸŽฏ \ No newline at end of file diff --git a/alembic.ini b/alembic.ini index 7f7f01d..137c002 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,147 +1,147 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the tzdata library which can be installed by adding -# `alembic[tz]` to the pip requirements. -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -# sqlalchemy.url = driver://user:pass@localhost/dbname - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module -# hooks = ruff -# ruff.type = module -# ruff.module = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Alternatively, use the exec runner to execute a binary found on your PATH -# hooks = ruff -# ruff.type = exec -# ruff.executable = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py index f7811b5..7661dc0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,78 +1,78 @@ -import asyncio -from logging.config import fileConfig - -from sqlalchemy.ext.asyncio import AsyncEngine -from sqlalchemy import engine_from_config -from sqlalchemy import pool -from sqlmodel import SQLModel -from sqlalchemy.engine import Connection - -# ------------------------------------------------------------------- -# 1. ๋ชจ๋ธ ๋ฐ ์„ค์ • ๋กœ๋“œ (Alembic์ด "๋ฌด์—‡์„" "์–ด๋””์—" ์ ์šฉํ• ์ง€ ์ •์˜) -# ------------------------------------------------------------------- - -# ์„ค๊ณ„๋„(Model) ๋กœ๋“œ: Alembic์ด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ๋ฅผ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ”๋ชจ๋ฆฌ์— ์˜ฌ๋ฆผ -from appserver.apps.account import models # noqa -from appserver.apps.calendar import models # noqa -# ์—ฐ๊ฒฐ ์ฃผ์†Œ: ํ”„๋กœ์ ํŠธ ์„ค์ • ํŒŒ์ผ์—์„œ ์‹ค์ œ DB ์ฃผ์†Œ(DSN)๋ฅผ ๊ฐ€์ ธ์˜ด -from appserver.db import DSN -# Alembic ์„ค์ • ๊ฐ์ฒด: alembic.ini ํŒŒ์ผ์˜ ๋‚ด์šฉ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์คŒ -from alembic import context - -config = context.config - -# ๋กœ๊น… ์„ค์ •: alembic.ini์˜ ์„ค์ •์„ ๋ฐ”ํƒ•์œผ๋กœ ๋กœ๊ทธ๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค (์ฝ˜์†”์— ์ฐํžˆ๋Š” ๋‚ด์šฉ๋“ค). -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# target_metadata: Alembic์ด ์ถ”์ ํ•  "๊ธฐ์ค€" ์„ค๊ณ„๋„์ž…๋‹ˆ๋‹ค. -target_metadata = SQLModel.metadata - -def run_migrations_offline() -> None: - """์˜คํ”„๋ผ์ธ ๋ชจ๋“œ: ์‹ค์ œ DB ์—ฐ๊ฒฐ ์—†์ด SQL ์Šคํฌ๋ฆฝํŠธ ํŒŒ์ผ๋งŒ ์ƒ์„ฑํ•  ๋•Œ ์ƒ์„ฑ""" - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url or DSN, # ini ํŒŒ์ผ์— ์ฃผ์†Œ๊ฐ€ ์—†์œผ๋ฉด ์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ DSN ์‚ฌ์šฉ - target_metadata=target_metadata, - literal_binds=True, # SQL์„ ํ…์ŠคํŠธ ํ˜•ํƒœ๋กœ ์ถœ๋ ฅ - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - - with context.begin_transaction(): - context.run_migrations() - - -async def run_migrations_online() -> None: - """์˜จ๋ผ์ธ ๋ชจ๋“œ: ์‹ค์ œ DB์— ์ ‘์†ํ•˜์—ฌ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•  ๋•Œ ์‹คํ–‰ (๋น„๋™๊ธฐ ๋ฐฉ์‹)""" - configuration = config.get_section(config.config_ini_section, {}) - - # ์‹ค์ œ DB ์ฃผ์†Œ ์ฃผ์ž…: ini ์„ค์ •๋ณด๋‹ค ์ฝ”๋“œ์ƒ์˜ DSN์„ ์šฐ์„ ์‹œํ•จ - configuration["sqlalchemy.url"] = DSN - - # ๋น„๋™๊ธฐ ์—”์ง„ ์ƒ์„ฑ: SQLAlchemy ์„ค์ •์„ ๋ฐ”ํƒ•์œผ๋กœ DB ํ†ต๋กœ๋ฅผ ๊ฐœ์„ค - connectable = AsyncEngine( - engine_from_config( - configuration, - prefix="sqlalchemy.", - poolclass=pool.NullPool, # ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹œ์—๋Š” ์ปค๋„ฅ์…˜ ํ’€์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ - ), - ) - # ์‹ค์ œ DB ์ ‘์† ํ›„ 'do_run_migrations' ํ•จ์ˆ˜๋ฅผ ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰(run_sync) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - # ์ž‘์—… ์™„๋ฃŒ ํ›„ ์—”์ง„ ์ž์› ํ•ด์ œ - await connectable.dispose() - -# --- ์‹คํ–‰๋ถ€ --- -if context.is_offline_mode(): - run_migrations_offline() -else: - # ๋น„๋™๊ธฐ ํ•จ์ˆ˜์ธ run_migrations_online์„ ์ด๋ฒคํŠธ ๋ฃจํ”„์—์„œ ์‹คํ–‰ +import asyncio +from logging.config import fileConfig + +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel +from sqlalchemy.engine import Connection + +# ------------------------------------------------------------------- +# 1. ๋ชจ๋ธ ๋ฐ ์„ค์ • ๋กœ๋“œ (Alembic์ด "๋ฌด์—‡์„" "์–ด๋””์—" ์ ์šฉํ• ์ง€ ์ •์˜) +# ------------------------------------------------------------------- + +# ์„ค๊ณ„๋„(Model) ๋กœ๋“œ: Alembic์ด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ๋ฅผ ํŒŒ์•…ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ”๋ชจ๋ฆฌ์— ์˜ฌ๋ฆผ +from appserver.apps.account import models # noqa +from appserver.apps.calendar import models # noqa +# ์—ฐ๊ฒฐ ์ฃผ์†Œ: ํ”„๋กœ์ ํŠธ ์„ค์ • ํŒŒ์ผ์—์„œ ์‹ค์ œ DB ์ฃผ์†Œ(DSN)๋ฅผ ๊ฐ€์ ธ์˜ด +from appserver.db import DSN +# Alembic ์„ค์ • ๊ฐ์ฒด: alembic.ini ํŒŒ์ผ์˜ ๋‚ด์šฉ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์คŒ +from alembic import context + +config = context.config + +# ๋กœ๊น… ์„ค์ •: alembic.ini์˜ ์„ค์ •์„ ๋ฐ”ํƒ•์œผ๋กœ ๋กœ๊ทธ๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค (์ฝ˜์†”์— ์ฐํžˆ๋Š” ๋‚ด์šฉ๋“ค). +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# target_metadata: Alembic์ด ์ถ”์ ํ•  "๊ธฐ์ค€" ์„ค๊ณ„๋„์ž…๋‹ˆ๋‹ค. +target_metadata = SQLModel.metadata + +def run_migrations_offline() -> None: + """์˜คํ”„๋ผ์ธ ๋ชจ๋“œ: ์‹ค์ œ DB ์—ฐ๊ฒฐ ์—†์ด SQL ์Šคํฌ๋ฆฝํŠธ ํŒŒ์ผ๋งŒ ์ƒ์„ฑํ•  ๋•Œ ์ƒ์„ฑ""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url or DSN, # ini ํŒŒ์ผ์— ์ฃผ์†Œ๊ฐ€ ์—†์œผ๋ฉด ์šฐ๋ฆฌ ํ”„๋กœ์ ํŠธ์˜ DSN ์‚ฌ์šฉ + target_metadata=target_metadata, + literal_binds=True, # SQL์„ ํ…์ŠคํŠธ ํ˜•ํƒœ๋กœ ์ถœ๋ ฅ + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """์˜จ๋ผ์ธ ๋ชจ๋“œ: ์‹ค์ œ DB์— ์ ‘์†ํ•˜์—ฌ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•  ๋•Œ ์‹คํ–‰ (๋น„๋™๊ธฐ ๋ฐฉ์‹)""" + configuration = config.get_section(config.config_ini_section, {}) + + # ์‹ค์ œ DB ์ฃผ์†Œ ์ฃผ์ž…: ini ์„ค์ •๋ณด๋‹ค ์ฝ”๋“œ์ƒ์˜ DSN์„ ์šฐ์„ ์‹œํ•จ + configuration["sqlalchemy.url"] = DSN + + # ๋น„๋™๊ธฐ ์—”์ง„ ์ƒ์„ฑ: SQLAlchemy ์„ค์ •์„ ๋ฐ”ํƒ•์œผ๋กœ DB ํ†ต๋กœ๋ฅผ ๊ฐœ์„ค + connectable = AsyncEngine( + engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, # ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹œ์—๋Š” ์ปค๋„ฅ์…˜ ํ’€์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + ), + ) + # ์‹ค์ œ DB ์ ‘์† ํ›„ 'do_run_migrations' ํ•จ์ˆ˜๋ฅผ ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰(run_sync) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + # ์ž‘์—… ์™„๋ฃŒ ํ›„ ์—”์ง„ ์ž์› ํ•ด์ œ + await connectable.dispose() + +# --- ์‹คํ–‰๋ถ€ --- +if context.is_offline_mode(): + run_migrations_offline() +else: + # ๋น„๋™๊ธฐ ํ•จ์ˆ˜์ธ run_migrations_online์„ ์ด๋ฒคํŠธ ๋ฃจํ”„์—์„œ ์‹คํ–‰ asyncio.run(run_migrations_online()) \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako index d75eb24..fe620e8 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -1,32 +1,32 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -import sqlalchemy_utc -import sqlmodel.sql.sqltypes -from sqlmodel import Text - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +import sqlalchemy_utc +import sqlmodel.sql.sqltypes +from sqlmodel import Text + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/310ac765367e_initialization.py b/alembic/versions/310ac765367e_initialization.py index fc004f0..5697526 100644 --- a/alembic/versions/310ac765367e_initialization.py +++ b/alembic/versions/310ac765367e_initialization.py @@ -1,99 +1,99 @@ -"""initialization - -Revision ID: 310ac765367e -Revises: -Create Date: 2025-12-28 01:09:54.943661 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -import sqlalchemy_utc -import sqlmodel.sql.sqltypes -from sqlmodel import Text - -# revision identifiers, used by Alembic. -revision: str = '310ac765367e' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), - sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), - sa.Column('password', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), - sa.Column('is_host', sa.Boolean(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email', name='uq_email'), - sa.UniqueConstraint('username') - ) - op.create_table('calendars', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('topics', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('google_calendar_id', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('host_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['host_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('host_id') - ) - op.create_table('oauth_accounts', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False), - sa.Column('provider_account_id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('provider', 'provider_account_id', name='uq_provider_provider_account_id') - ) - op.create_table('time_slots', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('start_time', sa.Time(), nullable=False), - sa.Column('end_time', sa.Time(), nullable=False), - sa.Column('weekdays', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), - sa.Column('calendar_id', sa.Integer(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['calendar_id'], ['calendars.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('bookings', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('when', sa.Date(), nullable=False), - sa.Column('topic', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('time_slot_id', sa.Integer(), nullable=False), - sa.Column('guest_id', sa.Integer(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['guest_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['time_slot_id'], ['time_slots.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('bookings') - op.drop_table('time_slots') - op.drop_table('oauth_accounts') - op.drop_table('calendars') - op.drop_table('users') - # ### end Alembic commands ### +"""initialization + +Revision ID: 310ac765367e +Revises: +Create Date: 2025-12-28 01:09:54.943661 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +import sqlalchemy_utc +import sqlmodel.sql.sqltypes +from sqlmodel import Text + +# revision identifiers, used by Alembic. +revision: str = '310ac765367e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('is_host', sa.Boolean(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email', name='uq_email'), + sa.UniqueConstraint('username') + ) + op.create_table('calendars', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('topics', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('google_calendar_id', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('host_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['host_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('host_id') + ) + op.create_table('oauth_accounts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False), + sa.Column('provider_account_id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('provider', 'provider_account_id', name='uq_provider_provider_account_id') + ) + op.create_table('time_slots', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('start_time', sa.Time(), nullable=False), + sa.Column('end_time', sa.Time(), nullable=False), + sa.Column('weekdays', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('calendar_id', sa.Integer(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['calendar_id'], ['calendars.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('bookings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('when', sa.Date(), nullable=False), + sa.Column('topic', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('time_slot_id', sa.Integer(), nullable=False), + sa.Column('guest_id', sa.Integer(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['guest_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['time_slot_id'], ['time_slots.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('bookings') + op.drop_table('time_slots') + op.drop_table('oauth_accounts') + op.drop_table('calendars') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/appserver/app.py b/appserver/app.py index 78d82ef..65220fc 100644 --- a/appserver/app.py +++ b/appserver/app.py @@ -1,9 +1,9 @@ -from fastapi import FastAPI -from .apps.account.endpoints import router as account_router - -app = FastAPI() - -def include_routers(_app: FastAPI): - _app.include_router(account_router) - +from fastapi import FastAPI +from .apps.account.endpoints import router as account_router + +app = FastAPI() + +def include_routers(_app: FastAPI): + _app.include_router(account_router) + include_routers(app) \ No newline at end of file diff --git a/appserver/apps/__init__.py b/appserver/apps/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/appserver/apps/__init__.py @@ -0,0 +1 @@ + diff --git a/appserver/apps/account/constants.py b/appserver/apps/account/constants.py new file mode 100644 index 0000000..c1f7de2 --- /dev/null +++ b/appserver/apps/account/constants.py @@ -0,0 +1,2 @@ +# ์ฟ ํ‚ค ์ด๋ฆ„์„ ํ•˜๋“œ ์ฝ”๋”ฉํ•˜์ง€ ์•Š๊ณ  ๊ด€๋ฆฌํ•˜๊ธฐ +AUTH_TOKEN_COOKIE_NAME = "auth_token" \ No newline at end of file diff --git a/appserver/apps/account/deps.py b/appserver/apps/account/deps.py new file mode 100644 index 0000000..8ec83ca --- /dev/null +++ b/appserver/apps/account/deps.py @@ -0,0 +1,80 @@ +from datetime import datetime, timezone, timedelta +from typing import Annotated + +from fastapi import Cookie, Depends +from sqlmodel import select + +from appserver.db import DbSessionDep +from .exceptions import ( + ExpiredTokenError, + InvalidTokenError, + UserNotFoundError, +) +from .models import User +from .utils import ACCESS_TOKEN_EXPIRE_MINUTES, decode_token +from sqlalchemy.ext.asyncio import AsyncSession + +# get_user ํ•จ์ˆ˜ - ํ† ํฐ์œผ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒํ•˜๋Š” ๊ณตํ†ต ๋กœ์ง +async def get_user(auth_token: str | None, db_session: AsyncSession) -> User | None: + if not auth_token: + return None + try: + decoded = decode_token(auth_token) + except Exception as e: + raise InvalidTokenError() from e + + expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + now = datetime.now(timezone.utc) + if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: + raise ExpiredTokenError() from e + + stmt = select(User).where(User.username == decoded["sub"]) + result = await db_session.execute(stmt) + + return result.scalar_one_or_none() + +async def get_current_user( + auth_token: Annotated[str, Cookie()], + db_session: DbSessionDep, +): + # # ์ฟ ํ‚ค์— ํ† ํฐ์ด ์—†์œผ๋ฉด ์ธ์ฆ ์‹คํŒจ + # if auth_token is None: + # raise InvalidTokenError() + + # # ํ† ํฐ ๋””์ฝ”๋”ฉ (์‹คํŒจ ์‹œ ์ธ์ฆ ์˜ค๋ฅ˜๋กœ ๋ณ€ํ™˜) + # try: + # decoded = decode_token(auth_token) + # except Exception as e: + # raise InvalidTokenError() from e + + # # ํ† ํฐ์˜ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ + # expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + # now = datetime.now(timezone.utc) + # if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: + # raise ExpiredTokenError() + + # # ํ† ํฐ์˜ subject(username)์œผ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + # stmt = select(User).where(User.username == decoded["sub"]) + # result = await db_session.execute(stmt) + # user = result.scalar_one_or_none() + user = await get_user(auth_token, db_session) + if user is None: + raise UserNotFoundError() + + return user + +# ์˜์กด์„ฑ ์ฃผ์ž…์šฉ ํƒ€์ž… ๋ณ„์นญ +CurrentUserDep = Annotated[User, Depends(get_current_user)] + +# get_current_user_optional ํ•จ์ˆ˜ - ์„ ํƒ์  ์ธ์ฆ ์˜์กด์„ฑ +async def get_current_user_optional( + db_session: DbSessionDep, + auth_token: Annotated[str | None, Cookie()] = None, +): + user = await get_user(auth_token, db_session) + return user + +# CurrentUserOptionalDep ํƒ€์ž… ๋ณ„์นญ +CurrentUserOptionalDep = Annotated[User | None, Depends(get_current_user_optional)] + + diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 3730777..4241612 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -1,31 +1,233 @@ -from datetime import datetime, timezone -from fastapi import APIRouter, HTTPException, status -from sqlmodel import select, SQLModel -from appserver.db import DbSessionDep -# .models์—์„œ User ๋ชจ๋ธ์„ ๊ฐ€์ ธ์˜ด (ํ˜„์žฌ ์ฝ”๋“œ์—์„œ ์ง์ ‘ ์“ฐ์ด์ง€ ์•Š์ง€๋งŒ ํ™•์žฅ์„ ์œ„ํ•œ ์กด์žฌ) -from .models import User - -# /account ๊ฒฝ๋กœ๋กœ ์‹œ์ž‘ํ•˜๋Š” API ๊ทธ๋ฃน ์ƒ์„ฑ -router = APIRouter(prefix="/account") - -@router.get("/users/{username}") -async def user_detail(username: str, session: DbSessionDep) -> User: - """ - ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํŠน์ • ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. - - ์ฃผ์˜: ํ˜„์žฌ dsn์ด ํ•จ์ˆ˜ ๋‚ด๋ถ€์— ์žˆ์–ด ๋งค ์š”์ฒญ๋งˆ๋‹ค ์—”์ง„์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. - ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ๊ธ€๋กœ๋ฒŒํ•˜๊ฒŒ ์„ ์–ธ๋œ ์—”์ง„์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์„ฑ๋Šฅ์ƒ ์œ ๋ฆฌํ•ฉ๋‹ˆ๋‹ค. - """ - # [Tip] ๊ฒฝ๋กœ ์ด์Šˆ๋ฅผ ๋ฐฉ์ง€ํ•˜๋ ค๋ฉด ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์“ฐ๊ฑฐ๋‚˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ํ™œ์šฉํ•˜์„ธ์š”. - stmt = select(User).where(User.username == username) - result = await session.execute(stmt) - user = result.scalar_one_or_none() - - if user is not None: - return user - - # DB์— ํ•ด๋‹น ์กฐ๊ฑด์˜ ์œ ์ €๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" - ) \ No newline at end of file +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, HTTPException, status +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError +from sqlmodel import func, select, update, delete + +from appserver.db import DbSessionDep +from .constants import AUTH_TOKEN_COOKIE_NAME +from .deps import CurrentUserDep +from .exceptions import ( + DuplicatedEmailError, + DuplicatedUsernameError, + PasswordMismatchError, + UserNotFoundError, +) +from .models import User +from .schemas import ( + LoginPayload, + SignupPayload, + UserDetailOut, + UserOut, +) +from .utils import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + create_access_token, + verify_password, +) +from .schemas import UpdateUserPayload + + +# /account ๊ฒฝ๋กœ๋กœ ์‹œ์ž‘ํ•˜๋Š” API ๊ทธ๋ฃน ์ƒ์„ฑ +router = APIRouter(prefix="/account") + +@router.get("/users/{username}") +async def user_detail(username: str, session: DbSessionDep) -> User: + """ + ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํŠน์ • ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + ์ฃผ์˜: ํ˜„์žฌ dsn์ด ํ•จ์ˆ˜ ๋‚ด๋ถ€์— ์žˆ์–ด ๋งค ์š”์ฒญ๋งˆ๋‹ค ์—”์ง„์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + ์‹ค์ œ ์„œ๋น„์Šค์—์„œ๋Š” ๊ธ€๋กœ๋ฒŒํ•˜๊ฒŒ ์„ ์–ธ๋œ ์—”์ง„์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์„ฑ๋Šฅ์ƒ ์œ ๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + """ + # [Tip] ๊ฒฝ๋กœ ์ด์Šˆ๋ฅผ ๋ฐฉ์ง€ํ•˜๋ ค๋ฉด ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์“ฐ๊ฑฐ๋‚˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ํ™œ์šฉํ•˜์„ธ์š”. + stmt = select(User).where(User.username == username) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + + if user is not None: + return user + + # DB์— ํ•ด๋‹น ์กฐ๊ฑด์˜ ์œ ์ €๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + +# ============ ํšŒ์›๊ฐ€์ž… API ============ +# response_model=UserOut: ์‘๋‹ต ์‹œ UserOut ์Šคํ‚ค๋งˆ๋กœ ํ•„ํ„ฐ๋ง (password ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด ์ œ์™ธ) +# status_code=201: ๋ฆฌ์†Œ์Šค ์ƒ์„ฑ ์„ฑ๊ณต ์‹œ HTTP 201 ๋ฐ˜ํ™˜ +@router.post("/signup", status_code=status.HTTP_201_CREATED, response_model=UserOut) +async def signup(payload: SignupPayload, session: DbSessionDep) -> User: + """ + ํšŒ์›๊ฐ€์ž… ์—”๋“œํฌ์ธํŠธ. + + ์ฒ˜๋ฆฌ ์ˆœ์„œ: + 1. ์ž…๋ ฅ ๊ฒ€์ฆ (SignupPayload์—์„œ ์ž๋™ ์ฒ˜๋ฆฌ) + 2. username ์ค‘๋ณต ์ฒดํฌ + 3. email ์ค‘๋ณต ์ฒดํฌ + 4. User ๋ชจ๋ธ๋กœ ๋ณ€ํ™˜ ๋ฐ DB ์ €์žฅ + 5. UserOut ํ˜•ํƒœ๋กœ ์‘๋‹ต (๋ฏผ๊ฐ์ •๋ณด ์ œ์™ธ) + """ + # ========== 1. username ์ค‘๋ณต ์ฒดํฌ ========== + # func.count(): SQL์˜ COUNT(*) ํ•จ์ˆ˜ + # select_from(User): User ํ…Œ์ด๋ธ”์—์„œ ์กฐํšŒ + # where(): ์กฐ๊ฑด ํ•„ํ„ฐ๋ง (SQL์˜ WHERE์ ˆ) + stmt = select(func.count()).select_from(User).where(User.username == payload.username) + result = await session.execute(stmt) # ๋น„๋™๊ธฐ๋กœ ์ฟผ๋ฆฌ ์‹คํ–‰ + count = result.scalar_one() # ๋‹จ์ผ ๊ฐ’(์ˆซ์ž) ์ถ”์ถœ + if count > 0: + raise DuplicatedUsernameError() # ์ปค์Šคํ…€ ์˜ˆ์™ธ ๋ฐœ์ƒ + + # ========== 2. email ์ค‘๋ณต ์ฒดํฌ ========== + stmt = select(func.count()).select_from(User).where(User.email == payload.email) + result = await session.execute(stmt) + count = result.scalar_one() + if count > 0: + raise DuplicatedEmailError() + + # ========== 3. ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ๋ฐ ์ €์žฅ ========== + # payload.model_dump(): SignupPayload ๊ฐ์ฒด โ†’ dict ๋ณ€ํ™˜ + # User.model_validate(): dict โ†’ User ๋ชจ๋ธ ๋ณ€ํ™˜ (๊ฒ€์ฆ ํฌํ•จ) + # from_attributes=True: ๊ฐ์ฒด์˜ ์†์„ฑ์—์„œ๋„ ๊ฐ’์„ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๊ฒŒ ํ•จ + user = User.model_validate(payload.model_dump(), from_attributes=True) + session.add(user) # ์„ธ์…˜์— ์ถ”๊ฐ€ (์•„์ง DB์— ์ €์žฅ ์•ˆ ๋จ) + + try: + # commit(): ์„ธ์…˜์˜ ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ DB์— ์‹ค์ œ๋กœ ๋ฐ˜์˜ + # ์ด ์‹œ์ ์— INSERT ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋จ + await session.commit() + except IntegrityError as e: + # Race Condition ๋Œ€๋น„: ์ค‘๋ณต ์ฒดํฌ์™€ ์ €์žฅ ์‚ฌ์ด์— ๋‹ค๋ฅธ ์š”์ฒญ์ด ๋ผ์–ด๋“ค ์ˆ˜ ์žˆ์Œ + # DB์˜ UNIQUE ์ œ์•ฝ์กฐ๊ฑด ์œ„๋ฐ˜ ์‹œ IntegrityError ๋ฐœ์ƒ + if "email" in str(e.orig): + raise DuplicatedEmailError() + else: + raise DuplicatedUsernameError() + + # ========== 4. ์‘๋‹ต ๋ฐ˜ํ™˜ ========== + # User ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜์ง€๋งŒ, response_model=UserOut ๋•Œ๋ฌธ์— + # ์‹ค์ œ๋กœ๋Š” UserOut ํ˜•ํƒœ๋กœ ํ•„ํ„ฐ๋ง๋˜์–ด ์‘๋‹ต๋จ (password ์ œ์™ธ) + return user + +@router.post("/login", status_code=status.HTTP_200_OK) +async def login(payload: LoginPayload, session: DbSessionDep) -> JSONResponse: + """ + ๋กœ๊ทธ์ธ ์š”์ฒญ์„ ์ˆ˜์‹ ํ•˜๊ณ , ์ž๊ฒฉ์ด ํ™•์ธ๋˜๋ฉด ํ† ํฐ๊ณผ ์ฟ ํ‚ค๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + 1. ์‚ฌ์šฉ์ž ์กฐํšŒ + 2. ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ + 3. JWT ์•ก์„ธ์Šค ํ† ํฐ ์ƒ์„ฑ (์‹ ๋ถ„์ฆ ๋ฐœ๊ธ‰) + 4. ์ฟ ํ‚ค์— ํ† ํฐ ์„ค์ • (์‹ ๋ถ„์ฆ์„ ์ง€๊ฐ‘์— ๋„ฃ๊ธฐ) + """ + # ========== 1. ์‚ฌ์šฉ์ž ์กฐํšŒ ========== + stmt = select(User).where(User.username == payload.username) + result = await session.execute(stmt) + # scalar_one_or_none(): + # - ๊ฒฐ๊ณผ๊ฐ€ 1๊ฐœ๋ฉด ํ•ด๋‹น ๊ฐ์ฒด ๋ฐ˜ํ™˜ + # - ์—†์œผ๋ฉด None ๋ฐ˜ํ™˜ + # - 2๊ฐœ ์ด์ƒ์ด๋ฉด ์—๋Ÿฌ ๋ฐœ์ƒ (Unique ์กฐ๊ฑด์ด ์žˆ์–ด์„œ 2๊ฐœ์ผ ์ˆ˜๋Š” ์—†์Œ) + user = result.scalar_one_or_none() + + if user is None: + raise UserNotFoundError() + + # ========== 2. ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ ========== + # ์ž…๋ ฅํ•œ ํ‰๋ฌธ ๋น„๋ฐ€๋ฒˆํ˜ธ(payload.password)์™€ DB์— ์ €์žฅ๋œ ํ•ด์‹œ(user.hashed_password) ๋น„๊ต + # verify_password ๋‚ด๋ถ€์—์„œ Argon2/Bcrypt ๋“ฑ์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒ€์ฆํ•จ + is_valid = verify_password(payload.password, user.hashed_password) + if not is_valid: + raise PasswordMismatchError() + + # ========== 3. ํ† ํฐ ์ƒ์„ฑ (์‹ ๋ถ„์ฆ ๋ฐœ๊ธ‰) ========== + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + # payload(๋‚ด์šฉ๋ฌผ)์—๋Š” ์‹๋ณ„ ๊ฐ€๋Šฅํ•œ ์ตœ์†Œํ•œ์˜ ์ •๋ณด๋ฅผ ๋‹ด์Šต๋‹ˆ๋‹ค. + # ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฐ™์€ ๋ฏผ๊ฐ ์ •๋ณด๋Š” ์ ˆ๋Œ€ ๋‹ด์œผ๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. + access_token = create_access_token( + data={ + "sub": user.username, # sub (Subject): ํ† ํฐ์˜ ์ฃผ์ธ (๋ณดํ†ต ID ์‚ฌ์šฉ) + "displayname": user.display_name, + "is_host": user.is_host, + }, + expires_delta=access_token_expires, + ) + + # ์‘๋‹ต ๋ณธ๋ฌธ(Body)์— ๋‹ด์„ ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + response_data = { + "access_token": access_token, + "token_type": "bearer", + "user": user.model_dump(mode="json", exclude={"hashed_password", "email"}) + } + + # ========== 4. ์ฟ ํ‚ค ์„ค์ • ๋ฐ ์‘๋‹ต (์ง€๊ฐ‘์— ๋„ฃ๊ธฐ) ========== + # JSONResponse ๊ฐ์ฒด๋ฅผ ์ง์ ‘ ์ƒ์„ฑํ•ด์•ผ ์ฟ ํ‚ค(Header)๋ฅผ ์กฐ์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + res = JSONResponse(response_data, status_code=status.HTTP_200_OK) + + # ํ˜„์žฌ ์‹œ๊ฐ„ (์ฟ ํ‚ค ๋งŒ๋ฃŒ ๊ณ„์‚ฐ์šฉ) + now = datetime.now(timezone.utc) + + # set_cookie: ๋ธŒ๋ผ์šฐ์ €์—๊ฒŒ "์ด ๋ฐ์ดํ„ฐ๋ฅผ ์ฟ ํ‚ค ์ €์žฅ์†Œ์— ์ €์žฅํ•ด!"๋ผ๊ณ  ๋ช…๋ น + res.set_cookie( + key=AUTH_TOKEN_COOKIE_NAME, # ์ฟ ํ‚ค ์ด๋ฆ„ (์˜ˆ: "access_token") + value=access_token, # ์ฟ ํ‚ค ๊ฐ’ (JWT ๋ฌธ์ž์—ด) + + # ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • (์ด ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์•Œ์•„์„œ ์ฟ ํ‚ค๋ฅผ ์‚ญ์ œํ•จ) + expires=now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + + # [๋ณด์•ˆ ์ค‘์š”] httponly=True: + # ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ(document.cookie)๋กœ ์ด ์ฟ ํ‚ค์— ์ ‘๊ทผํ•  ์ˆ˜ ์—†๊ฒŒ ๋ง‰์Œ. + # XSS(๊ต์ฐจ ์‚ฌ์ดํŠธ ์Šคํฌ๋ฆฝํŒ…) ๊ณต๊ฒฉ ์‹œ ํ† ํฐ ํƒˆ์ทจ๋ฅผ ๋ฐฉ์ง€ํ•˜๋Š” ํ•ต์‹ฌ ์˜ต์…˜. + httponly=True, + + # [๋ณด์•ˆ ์ค‘์š”] secure=True: + # HTTPS(์•”ํ˜ธํ™”๋œ ์—ฐ๊ฒฐ)์ธ ๊ฒฝ์šฐ์—๋งŒ ์ฟ ํ‚ค๋ฅผ ์„œ๋ฒ„๋กœ ์ „์†กํ•จ. + # ๋„คํŠธ์›Œํฌ ์Šค๋‹ˆํ•‘(ํŒจํ‚ท ๊ฐ€๋กœ์ฑ„๊ธฐ)์œผ๋กœ๋ถ€ํ„ฐ ํ† ํฐ์„ ๋ณดํ˜ธํ•จ. (๋กœ์ปฌ ๊ฐœ๋ฐœ์—์„  http์—ฌ๋„ ๋™์ž‘ํ•˜๊ฒŒ ์„ค์ • ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Œ) + secure=True, + + # [๋ณด์•ˆ ์ค‘์š”] samesite="strict": + # ๋‹ค๋ฅธ ์‚ฌ์ดํŠธ์—์„œ ์šฐ๋ฆฌ ์‚ฌ์ดํŠธ๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ๋•Œ ์ฟ ํ‚ค๋ฅผ ์ „์†กํ•˜์ง€ ์•Š์Œ. + # CSRF(์‚ฌ์ดํŠธ ๊ฐ„ ์š”์ฒญ ์œ„์กฐ) ๊ณต๊ฒฉ์„ ๋ฐฉ์ง€ํ•จ. + samesite="strict" + ) + return res + +@router.get("/@me", response_model=UserDetailOut) +async def me(user: CurrentUserDep) -> User: + """ + ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + Args: + user (CurrentUserDep): + FastAPI์˜ Dependency Injection ์‹œ์Šคํ…œ์ด ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. + 1. ์š”์ฒญ์˜ ์ฟ ํ‚ค์—์„œ ํ† ํฐ์„ ๊บผ๋ƒ…๋‹ˆ๋‹ค. + 2. ํ† ํฐ์„ ๊ฒ€์ฆํ•˜๊ณ  ๋””์ฝ”๋”ฉํ•˜์—ฌ username์„ ์–ป์Šต๋‹ˆ๋‹ค. + 3. DB์—์„œ ํ•ด๋‹น username์„ ์กฐํšŒํ•˜์—ฌ User ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด ์—ฌ๊ธฐ์— ์ฃผ์ž…ํ•ด์ค๋‹ˆ๋‹ค. + (์ด ๋ชจ๋“  ๊ณผ์ •์€ deps.py์˜ get_current_user ํ•จ์ˆ˜์—์„œ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค) + """ + return user + +@router.patch("/@me", response_model=UserDetailOut) +async def update_user( + user: CurrentUserDep, + payload: UpdateUserPayload, + session: DbSessionDep +) -> User: + updated_data = payload.model_dump(exclude_unset=True, exclude={"password", "password_again"}) + stmt = update(User).where(User.username == user.username).values(**updated_data) + await session.execute(stmt) + await session.commit() + return user + +@router.delete("/logout", status_code=status.HTTP_200_OK) +async def logout(user: CurrentUserDep) -> JSONResponse: + res = JSONResponse({}) + res.delete_cookie(AUTH_TOKEN_COOKIE_NAME) + return res + +@router.delete("/unregister", status_code=status.HTTP_204_NO_CONTENT) +async def unregister(user: CurrentUserDep, session:DbSessionDep) -> None: + stmt = delete(User).where(User.username == user.username) + await session.execute(stmt) + await session.commit() + return None diff --git a/appserver/apps/account/exceptions.py b/appserver/apps/account/exceptions.py new file mode 100644 index 0000000..b48ce87 --- /dev/null +++ b/appserver/apps/account/exceptions.py @@ -0,0 +1,47 @@ +from fastapi import HTTPException, status + +class DuplicatedUsernameError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="์ค‘๋ณต๋œ ๊ณ„์ • ID์ž…๋‹ˆ๋‹ค.", + ) + +class DuplicatedEmailError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="์ค‘๋ณต๋œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.", + ) + +class UserNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + +class PasswordMismatchError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Password mismatch", + ) + +class InvalidTokenError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="์œ ํšจํ•˜์ง€ ์•Š์€ ์ธ์ฆ ํ† ํฐ์ž…๋‹ˆ๋‹ค", + headers={"WWW-Authenticate": "Bearer"} + ) + +class ExpiredTokenError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="์ธ์ฆ ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + headers={"WWW-Authenticate": "Bearer"}, + ) + + \ No newline at end of file diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index f63f9d8..768781e 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -1,106 +1,130 @@ -from datetime import datetime, timezone -from sqlmodel import SQLModel, Field, Relationship, func, Column, AutoString -from pydantic import EmailStr, AwareDatetime # ์ด๋ฉ”์ผ ํ˜•์‹์ด ์œ ํšจํ•œ์ง€(@ ํฌํ•จ ๋“ฑ) ์ž๋™์œผ๋กœ ๊ฒ€์‚ฌํ•ด์ฃผ๋Š” ๋„๊ตฌ -from sqlalchemy import UniqueConstraint # ํŠน์ • ์ปฌ๋Ÿผ์˜ ๊ฐ’์ด ์ค‘๋ณต๋˜์ง€ ์•Š๋„๋ก DB ๋ ˆ๋ฒจ์—์„œ ๊ฐ•์ œํ•˜๋Š” -from sqlalchemy_utc import UtcDateTime -from typing import TYPE_CHECKING, Union - -if TYPE_CHECKING: - from appserver.apps.calendar.models import Calendar, Booking - -class User(SQLModel, table=True): - __tablename__ = "users" # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ”์˜ ์ด๋ฆ„ ์ง€์ • # table=True ์ธ์ž๋ฅผ ์ง€์ •ํ•œ ๊ฒฝ์œ  ์œ ํšจ - - # DB ์ œ์•ฝ ์กฐ๊ฑด ์„ค์ •: "email" ์ปฌ๋Ÿผ์— ์ค‘๋ณต๋œ ๊ฐ’์ด ๋“ค์–ด์˜ฌ ์ˆ˜ ์—†๋„๋ก ์ด๋ฆ„(uq_email)์„ ๋ถ™์—ฌ ์„ค์ •ํ•จ - __table_args__ = ( - UniqueConstraint("email", name="uq_email"), - ) - - # ID: ๊ธฐ๋ณธํ‚ค(PK). ์ฒ˜์Œ ๊ฐ์ฒด ์ƒ์„ฑ์‹œ None์ด์ง€๋งŒ DB ์ €์žฅ ์‹œ ์ž๋™ ์ƒ์„ฑ๋จ - id: int = Field(default=None, primary_key=True) - - # username: ๊ณ ์œ ํ•ด์•ผ ํ•˜๋ฉฐ, ์ตœ๋Œ€ 40์ž ์ œํ•œ, description์€ Swagger ๋ฌธ์„ฑ์— ์„ค๋ช…์œผ๋กœ ํ‘œ์‹œ๋จ - username: str = Field(unique=True, max_length=40, description="์‚ฌ์šฉ์ž ๊ณ„์ • ID") - - # email: Pydantic์˜ EamilStr์„ ์‚ฌ์šฉํ•ด ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ์ง„์งœ ์ด๋ฉ”์ผ ํ˜•์‹์ธ์ง€ ๊ฒ€์ฆํ•จ - email: EmailStr = Field(max_length=128, description="์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ") - - # display_name: ์„œ๋น„์Šค์—์„œ ๋ณด์—ฌ์งˆ ๋ณ„๋ช…, ๊ธธ์ด๋ฅผ ์ œํ•œํ•˜์—ฌ DB ๊ณต๊ฐ„ ํšจ์œจ ์ฆ๋Œ€ - display_name: str = Field(max_length=40, description="์‚ฌ์šฉ์ž ํ‘œ์‹œ ์ด๋ฆ„") - - # password: ์‹ค์ œ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ €์žฅ๋  ๊ณณ, ๋‚˜์ค‘์— ํ•ด์‹ฑ(์•”ํ˜ธํ™”)๋œ ๋ฌธ์ž์—ด์ด ์ €์žฅ๋  ์˜ˆ์ • - password: str = Field(max_length=128, description="์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ") - # is_host: ํ˜ธ์ŠคํŠธ/๊ฒŒ์ŠคํŠธ ๊ตฌ๋ถ„์šฉ, ๊ธฐ๋ณธ๊ฐ’ False(๊ฒŒ์ŠคํŠธ)๋กœ ์„ค์ • - - is_host: bool = Field(default=False, description="์‚ฌ์šฉ์ž๊ฐ€ ํ˜ธ์ŠคํŠธ์ธ์ง€ ์—ฌ๋ถ€") - # ์ƒ์„ฑ/ ์ˆ˜์ • ์‹œ๊ฐ„: ๋ฐ์ดํ„ฐ์˜ ์ด๋ ฅ์„ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•œ ํ•„์ˆ˜ ํ•„๋“œ - created_at: AwareDatetime = Field( - default=None, # ํŒŒ์ด์ฌ์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” default - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” server_default - }, # func๋Š” SQLAlchemy์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฐ์ฒด๋กœ, ๊ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํ˜„์žฌ ์ผ์‹œ ๊ฐ’์„ ๋งŒ๋“œ๋Š” ํ•จ์ˆ˜ - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - # ORM์˜ ๊ฐ์ฒด ๋ฐ์ดํ„ฐ๊ฐ€ ๊ฐฑ์‹ ๋  ๋•Œ ํ˜ธ์ถœ๋  ํŒŒ์ด์ฌ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Œ - }, - ) - - oauth_accounts: list["OAuthAccount"] = Relationship(back_populates="user") - # list["OAuthAccount"]: ํ•œ ๋ช…์˜ ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์†Œ์…œ ๊ณ„์ •์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋Š” 1:N(์ผ๋Œ€ ๋‹ค) ๊ด€๊ณ„๋ฅผ ์˜๋ฏธ - # ๋”ฐ์˜ดํ‘œ(" "): OAuthAccount ํด๋ž˜์Šค๊ฐ€ ์•„๋ž˜์˜ ์ •์˜๋˜์–ด ์žˆ์–ด, ํŒŒ์ด์ฌ์ด ๋ฏธ๋ฆฌ ์•Œ ์ˆ˜์žˆ๊ฒŒ '๋ฌธ์ž์—ด'๋กœ ํƒ€์ž…์„ ๋ช…์‹œ ํ•œ ๊ฒƒ (Forward Refrence) - # back_populates: ์–‘๋ฐฉํ–ฅ ์—ฐ๊ฒฐ ์„ค์ •. OAuthAccount ์ชฝ์—์„œ๋„ .user๋ฅผ ํ†ตํ•ด ์ด ์‚ฌ์šฉ์ž๋ฅผ ๋ฐ”๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•จ. - - calendar: Union["Calendar", None] = Relationship( - back_populates="host", - sa_relationship_kwargs={"uselist": False, "single_parent": True}, - ) - bookings: list["Booking"] = Relationship(back_populates="guest") - -class OAuthAccount(SQLModel, table=True): - __tablename__ = "oauth_accounts" - __table_args__ = ( - UniqueConstraint( - "provider", - "provider_account_id", - name="uq_provider_provider_account_id", - ), # ์‰ผํ‘œ(,) ์ถ”๊ฐ€ ํŠœํ”Œ - ) - # ํ•œ ๋ช…์˜ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์ผํ•œ ์ œ๊ณต์ž(์˜ˆ: ์นด์นด์˜ค)์˜ ๋™์ผํ•œ ๊ฒŒ์ •์œผ๋กœ - # ์ค‘๋ณต ๊ฐ€์ž…๋˜๋Š” ๊ฒƒ์„ DB ๋ ˆ๋ฒจ์—์„œ ์›์ฒœ ๋ด‰์‡„ํ•จ (๋ณด์•ˆ ๋ฐ ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ) - - id: int = Field(default=None, primary_key=True) - provider: str = Field(max_length=10, description="OAuth ์ œ๊ณต์ž") - # google, kakao, github ๋“ฑ ์†Œ์…œ ๋กœ๊ทธ์ธ ์„œ๋น„์Šค ์ด๋ฆ„์„ ์ €์žฅ - provider_account_id: str = Field(max_length=128, description="OAuth ์ œ๊ณต์ž ๊ณ„์ • ID") - # ์™ธ๋ถ€ ์„œ๋น„์Šค์—์„œ ์šฐ๋ฆฌ์—๊ฒŒ ๋„˜๊ฒจ์ฃผ๋Š” ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ๊ณ ์œ  ์‹๋ณ„ ๋ฒˆํ˜ธ - user_id: int = Field(foreign_key="users.id") - # users ํ…Œ์ด๋ธ”์˜ id ์ปฌ๋Ÿผ์„ ์ฐธ์กฐํ•˜๋Š” ์™ธ๋ž˜ํ‚ค - # ์ด ๊ณ„์ •์ด ์–ด๋–ค ์œ ์ €์˜ ์†Œ์œ ์ธ์ง€ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์—ฐ๊ฒฐ - - user: User = Relationship(back_populates="oauth_accounts") - - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, # DB ์ €์žฅ ์‹œ ํƒ€์ž„์กด ์ •๋ณด๋ฅผ ํฌํ•จํ•˜์—ฌ ํ•ญ์ƒ UTC ๊ธฐ์ค€์œผ๋กœ ์ €์žฅ๋˜๋„๋ก ๊ฐ•์ œ. - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), # ๋ฐ์ดํ„ฐ๊ฐ€ ์ˆ˜์ •๋  ๋–„๋งˆ๋‹ค ํŒŒ์ด์ฌ์ด ์‹คํ–‰ ์‹œ์ ์˜ UTC ์‹œ๊ฐ„์„ ๊ณ„์‚ฐํ•ด์„œ ๋„ฃ์–ด์คŒ. - }, +from datetime import datetime, timezone +from sqlmodel import SQLModel, Field, Relationship, func, Column, AutoString +from pydantic import EmailStr, AwareDatetime # ์ด๋ฉ”์ผ ํ˜•์‹์ด ์œ ํšจํ•œ์ง€(@ ํฌํ•จ ๋“ฑ) ์ž๋™์œผ๋กœ ๊ฒ€์‚ฌํ•ด์ฃผ๋Š” ๋„๊ตฌ +from sqlalchemy import UniqueConstraint # ํŠน์ • ์ปฌ๋Ÿผ์˜ ๊ฐ’์ด ์ค‘๋ณต๋˜์ง€ ์•Š๋„๋ก DB ๋ ˆ๋ฒจ์—์„œ ๊ฐ•์ œํ•˜๋Š” +from sqlalchemy_utc import UtcDateTime +from typing import TYPE_CHECKING, Union +import random +import string +from pydantic import model_validator + + +if TYPE_CHECKING: + from appserver.apps.calendar.models import Calendar, Booking + +class User(SQLModel, table=True): + __tablename__ = "users" # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ”์˜ ์ด๋ฆ„ ์ง€์ • # table=True ์ธ์ž๋ฅผ ์ง€์ •ํ•œ ๊ฒฝ์œ  ์œ ํšจ + + # DB ์ œ์•ฝ ์กฐ๊ฑด ์„ค์ •: "email" ์ปฌ๋Ÿผ์— ์ค‘๋ณต๋œ ๊ฐ’์ด ๋“ค์–ด์˜ฌ ์ˆ˜ ์—†๋„๋ก ์ด๋ฆ„(uq_email)์„ ๋ถ™์—ฌ ์„ค์ •ํ•จ + __table_args__ = ( + UniqueConstraint("email", name="uq_email"), + ) + + # ID: ๊ธฐ๋ณธํ‚ค(PK). ์ฒ˜์Œ ๊ฐ์ฒด ์ƒ์„ฑ์‹œ None์ด์ง€๋งŒ DB ์ €์žฅ ์‹œ ์ž๋™ ์ƒ์„ฑ๋จ + id: int = Field(default=None, primary_key=True) + + # username: ๊ณ ์œ ํ•ด์•ผ ํ•˜๋ฉฐ, ์ตœ๋Œ€ 40์ž ์ œํ•œ, description์€ Swagger ๋ฌธ์„ฑ์— ์„ค๋ช…์œผ๋กœ ํ‘œ์‹œ๋จ + username: str = Field(min_length=4,unique=True, max_length=40, description="์‚ฌ์šฉ์ž ๊ณ„์ • ID") + + # email: Pydantic์˜ EamilStr์„ ์‚ฌ์šฉํ•ด ๋ฌธ์ž์—ด์ด ์•„๋‹Œ ์ง„์งœ ์ด๋ฉ”์ผ ํ˜•์‹์ธ์ง€ ๊ฒ€์ฆํ•จ + email: EmailStr = Field(max_length=128, description="์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ") + + # display_name: ์„œ๋น„์Šค์—์„œ ๋ณด์—ฌ์งˆ ๋ณ„๋ช…, ๊ธธ์ด๋ฅผ ์ œํ•œํ•˜์—ฌ DB ๊ณต๊ฐ„ ํšจ์œจ ์ฆ๋Œ€ + display_name: str = Field(min_length=4, max_length=40, description="์‚ฌ์šฉ์ž ํ‘œ์‹œ ์ด๋ฆ„") + + # hashed_password: ์‹ค์ œ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ €์žฅ๋  ๊ณณ, ๋‚˜์ค‘์— ํ•ด์‹ฑ(์•”ํ˜ธํ™”)๋œ ๋ฌธ์ž์—ด์ด ์ €์žฅ๋  ์˜ˆ์ • + hashed_password: str = Field(min_length=4, max_length=128, description="์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ") + + # password: ์‹ค์ œ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ €์žฅ๋  ๊ณณ, ๋‚˜์ค‘์— ํ•ด์‹ฑ(์•”ํ˜ธํ™”)๋œ ๋ฌธ์ž์—ด์ด ์ €์žฅ๋  ์˜ˆ์ • + password: str = Field(min_length=4, max_length=128, description="์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ") + # is_host: ํ˜ธ์ŠคํŠธ/๊ฒŒ์ŠคํŠธ ๊ตฌ๋ถ„์šฉ, ๊ธฐ๋ณธ๊ฐ’ False(๊ฒŒ์ŠคํŠธ)๋กœ ์„ค์ • + + is_host: bool = Field(default=False, description="์‚ฌ์šฉ์ž๊ฐ€ ํ˜ธ์ŠคํŠธ์ธ์ง€ ์—ฌ๋ถ€") + # ์ƒ์„ฑ/ ์ˆ˜์ • ์‹œ๊ฐ„: ๋ฐ์ดํ„ฐ์˜ ์ด๋ ฅ์„ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•œ ํ•„์ˆ˜ ํ•„๋“œ + created_at: AwareDatetime = Field( + default=None, # ํŒŒ์ด์ฌ์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” default + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” server_default + }, # func๋Š” SQLAlchemy์—์„œ ์ œ๊ณตํ•˜๋Š” ๊ฐ์ฒด๋กœ, ๊ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํ˜„์žฌ ์ผ์‹œ ๊ฐ’์„ ๋งŒ๋“œ๋Š” ํ•จ์ˆ˜ + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + # ORM์˜ ๊ฐ์ฒด ๋ฐ์ดํ„ฐ๊ฐ€ ๊ฐฑ์‹ ๋  ๋•Œ ํ˜ธ์ถœ๋  ํŒŒ์ด์ฌ ๊ฐ์ฒด๋ฅผ ๋ฐ›์Œ + }, + ) + + oauth_accounts: list["OAuthAccount"] = Relationship(back_populates="user") + # list["OAuthAccount"]: ํ•œ ๋ช…์˜ ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์†Œ์…œ ๊ณ„์ •์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋Š” 1:N(์ผ๋Œ€ ๋‹ค) ๊ด€๊ณ„๋ฅผ ์˜๋ฏธ + # ๋”ฐ์˜ดํ‘œ(" "): OAuthAccount ํด๋ž˜์Šค๊ฐ€ ์•„๋ž˜์˜ ์ •์˜๋˜์–ด ์žˆ์–ด, ํŒŒ์ด์ฌ์ด ๋ฏธ๋ฆฌ ์•Œ ์ˆ˜์žˆ๊ฒŒ '๋ฌธ์ž์—ด'๋กœ ํƒ€์ž…์„ ๋ช…์‹œ ํ•œ ๊ฒƒ (Forward Refrence) + # back_populates: ์–‘๋ฐฉํ–ฅ ์—ฐ๊ฒฐ ์„ค์ •. OAuthAccount ์ชฝ์—์„œ๋„ .user๋ฅผ ํ†ตํ•ด ์ด ์‚ฌ์šฉ์ž๋ฅผ ๋ฐ”๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•จ. + + calendar: Union["Calendar", None] = Relationship( + back_populates="host", + sa_relationship_kwargs={"uselist": False, "single_parent": True}, + ) + bookings: list["Booking"] = Relationship(back_populates="guest") + + @model_validator(mode="before") # ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ ์ „์— ์‹คํ–‰, Pydantic ๋ชจ๋ธ ์ƒ์„ฑ ์ „์— ์‹คํ–‰ + @classmethod + def generate_display_name(cls, data: dict): + if not data.get("display_name"): + data["display_name"] = "".join( + random.choices( + string.ascii_letters + string.digits, + k=8, + ) + ) + return data + # @model_validator(mode="after") + # pydantic์ด ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ๋ชจ๋ธ ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค๊ธฐ ์ง์ „์— ์ด ํ•จ์ˆ˜๋ฅผ ์‹คํ–‰ํ•˜๋ผ๋Š” ๋œป + # mode = "after" ์ผ ๋•Œ๋Š” ์ž…๋ ฅ๋ฐ›์€ ๋ฐ์ดํ„ฐ๊ฐ€ ์•„์ง ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ์ž…๋‹ˆ๋‹ค. ๋ฐ์ดํ„ฐ ํƒ€์ž…์„ ๋งž์ถ”๊ฑฐ๋‚˜, + # ๋น ์ง„ ๊ฐ’์„ ์ฑ„์›Œ๋„ฃ๋Š” ์ „์ฒ˜๋ฆฌ ๋‹จ๊ณ„์—์„œ ์ฃผ๋กœ ์‚ฌ์šฉ + # ํ•จ์ˆ˜๊ฐ€ ํŠน์ • ์ธ์Šคํ„ด์Šค๊ฐ€ ์•„๋‹ˆ๋ผ ํด๋ž˜์Šค ์ž์ฒด์— ์†ํ•จ + +class OAuthAccount(SQLModel, table=True): + __tablename__ = "oauth_accounts" + __table_args__ = ( + UniqueConstraint( + "provider", + "provider_account_id", + name="uq_provider_provider_account_id", + ), # ์‰ผํ‘œ(,) ์ถ”๊ฐ€ ํŠœํ”Œ + ) + # ํ•œ ๋ช…์˜ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์ผํ•œ ์ œ๊ณต์ž(์˜ˆ: ์นด์นด์˜ค)์˜ ๋™์ผํ•œ ๊ฒŒ์ •์œผ๋กœ + # ์ค‘๋ณต ๊ฐ€์ž…๋˜๋Š” ๊ฒƒ์„ DB ๋ ˆ๋ฒจ์—์„œ ์›์ฒœ ๋ด‰์‡„ํ•จ (๋ณด์•ˆ ๋ฐ ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ) + + id: int = Field(default=None, primary_key=True) + provider: str = Field(max_length=10, description="OAuth ์ œ๊ณต์ž") + # google, kakao, github ๋“ฑ ์†Œ์…œ ๋กœ๊ทธ์ธ ์„œ๋น„์Šค ์ด๋ฆ„์„ ์ €์žฅ + provider_account_id: str = Field(max_length=128, description="OAuth ์ œ๊ณต์ž ๊ณ„์ • ID") + # ์™ธ๋ถ€ ์„œ๋น„์Šค์—์„œ ์šฐ๋ฆฌ์—๊ฒŒ ๋„˜๊ฒจ์ฃผ๋Š” ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ๊ณ ์œ  ์‹๋ณ„ ๋ฒˆํ˜ธ + user_id: int = Field(foreign_key="users.id") + # users ํ…Œ์ด๋ธ”์˜ id ์ปฌ๋Ÿผ์„ ์ฐธ์กฐํ•˜๋Š” ์™ธ๋ž˜ํ‚ค + # ์ด ๊ณ„์ •์ด ์–ด๋–ค ์œ ์ €์˜ ์†Œ์œ ์ธ์ง€ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์—ฐ๊ฒฐ + + user: User = Relationship(back_populates="oauth_accounts") + + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, # DB ์ €์žฅ ์‹œ ํƒ€์ž„์กด ์ •๋ณด๋ฅผ ํฌํ•จํ•˜์—ฌ ํ•ญ์ƒ UTC ๊ธฐ์ค€์œผ๋กœ ์ €์žฅ๋˜๋„๋ก ๊ฐ•์ œ. + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), # ๋ฐ์ดํ„ฐ๊ฐ€ ์ˆ˜์ •๋  ๋–„๋งˆ๋‹ค ํŒŒ์ด์ฌ์ด ์‹คํ–‰ ์‹œ์ ์˜ UTC ์‹œ๊ฐ„์„ ๊ณ„์‚ฐํ•ด์„œ ๋„ฃ์–ด์คŒ. + }, ) \ No newline at end of file diff --git a/appserver/apps/account/schemas.py b/appserver/apps/account/schemas.py new file mode 100644 index 0000000..3f6a7c8 --- /dev/null +++ b/appserver/apps/account/schemas.py @@ -0,0 +1,87 @@ +import random +import string +from typing_extensions import Self # Python 3.10 ํ˜ธํ™˜์„ฑ +from pydantic import model_validator, EmailStr, AwareDatetime, computed_field +from sqlmodel import SQLModel, Field +from .utils import hash_password + +# ============ ํšŒ์›๊ฐ€์ž… ์ž…๋ ฅ ์Šคํ‚ค๋งˆ ============ +# ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ๋ฐ›๋Š” ํšŒ์›๊ฐ€์ž… ๋ฐ์ดํ„ฐ์˜ ํ˜•์‹์„ ์ •์˜ +# Field()๋กœ ๊ฐ ํ•„๋“œ์˜ ๊ฒ€์ฆ ๊ทœ์น™ ์„ค์ • +class SignupPayload(SQLModel): + username: str = Field(min_length=4, unique=True, max_length=40, description="์‚ฌ์šฉ์ž ๊ณ„์ • ID") + email: EmailStr = Field(unique=True, max_length=128, description="์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ") # EmailStr: ์ด๋ฉ”์ผ ํ˜•์‹ ์ž๋™ ๊ฒ€์ฆ + display_name: str = Field(min_length=4, max_length=40, description="์‚ฌ์šฉ์ž ํ‘œ์‹œ ์ด๋ฆ„") + password: str = Field(min_length=8, max_length=128, description="์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ") + password_again: str = Field(min_length=8, max_length=128, description="์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ") + + # ========== Validator 1: ๋น„๋ฐ€๋ฒˆํ˜ธ ์ผ์น˜ ๊ฒ€์ฆ ========== + # mode="after": ๋ชจ๋“  ํ•„๋“œ ๊ฒ€์ฆ์ด ๋๋‚œ ํ›„ ์‹คํ–‰ (์ด๋ฏธ SignupPayload ๊ฐ์ฒด๊ฐ€ ์ƒ์„ฑ๋œ ์ƒํƒœ) + # Self: ์ž๊ธฐ ์ž์‹ ์˜ ํƒ€์ž…(SignupPayload)์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค๋Š” ์˜๋ฏธ + @model_validator(mode="after") + def verify_password(self) -> Self: + if self.password != self.password_again: + raise ValueError("Passwords do not match") + return self + + # ========== Validator 2: display_name ์ž๋™ ์ƒ์„ฑ ========== + # mode="before": ํ•„๋“œ ๊ฒ€์ฆ ์ „์— ์‹คํ–‰ (์•„์ง dict ์ƒํƒœ) + # @classmethod: ์ธ์Šคํ„ด์Šค๊ฐ€ ์•„๋‹Œ ํด๋ž˜์Šค ์ž์ฒด๋ฅผ ๋ฐ›์Œ (cls = SignupPayload) + @model_validator(mode="before") + @classmethod + def generate_display_name(cls, data: dict) -> dict: + """display_name์ด ์—†์œผ๋ฉด ๋žœ๋ค 8์ž๋ฆฌ ๋ฌธ์ž์—ด ์ƒ์„ฑ.""" + if not data.get("display_name"): + # string.ascii_letters: 'abcd...xyzABC...XYZ' + # string.digits: '0123456789' + # random.choices(pool, k=8): pool์—์„œ 8๊ฐœ ๋žœ๋ค ์„ ํƒ (์ค‘๋ณต ํ—ˆ์šฉ) + data["display_name"] = "".join( + random.choices( + string.ascii_letters + string.digits, + k=8, + ) + ) + return data + +# ============ ํšŒ์›๊ฐ€์ž… ์‘๋‹ต ์Šคํ‚ค๋งˆ ============ +# API ์‘๋‹ต ์‹œ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ๋ณด์—ฌ์ค„ ํ•„๋“œ๋งŒ ์ •์˜ +# password, email ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด๋Š” ์ œ์™ธ +class UserOut(SQLModel): + username: str + display_name: str + is_host: bool + +class LoginPayload(SQLModel): + """๋กœ๊ทธ์ธ API์—์„œ ๋ฐ›๋Š” ํŽ˜์ด๋กœ๋“œ""" + username: str = Field(min_length=4, max_length=40) + password: str = Field(min_length=8, max_length=128) + +class UserDetailOut(UserOut): + email: EmailStr + created_at: AwareDatetime + updated_at: AwareDatetime + +class UpdateUserPayload(SQLModel): + display_name: str | None = Field(default=None, min_length=4, max_length=40) + email: EmailStr | None = Field(default=None, unique=True, max_length=128) + password: str | None = Field(default=None, min_length=8, max_length=128) + password_again: str | None = Field(default=None, min_length=8, max_length=128) + + @model_validator(mode="after") + def check_all_fields_are_none(self) -> Self: + if not self.model_dump(exclude_none=True): + raise ValueError("์ตœ์†Œ ํ•˜๋‚˜์˜ ํ•„๋“œ๋Š” ๋ฐ˜๋“œ์‹œ ์ œ๊ณต๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + return self + + @model_validator(mode="after") + def verify_password(self) -> Self: + if self.password != self.password_again: + raise ValueError("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + return self + + @computed_field + @property + def hashed_password(self) -> str | None: + if self.password: + return hash_password(self.password) + return None \ No newline at end of file diff --git a/appserver/apps/account/utils.py b/appserver/apps/account/utils.py new file mode 100644 index 0000000..6924978 --- /dev/null +++ b/appserver/apps/account/utils.py @@ -0,0 +1,64 @@ +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from pwdlib.hashers.bcrypt import BcryptHasher # โš ๏ธ ์˜คํƒ€ ์ฃผ์˜: bcrypt +from datetime import datetime, timedelta, timezone +from jose import jwt +from typing import Any, Union + +SECRET_KEY = "your-secret-key" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +def hash_password(password: str) -> str: + """ + ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ํ•ด์‹œ(์•”ํ˜ธํ™”)ํ•˜์—ฌ ๋ฐ˜ํ™˜ + ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ํ‰๋ฌธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ Argon2 ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•˜์—ฌ + ๋ณตํ˜ธํ™”๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•œ ํ•ด์‹œ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ + + Args: + password(str): ํ‰๋ฌธ ๋น„๋ฐ€๋ฒˆํ˜ธ + + Returns: + str: ์•”ํ˜ธํ™”๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ + """ + password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) + return password_hash.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + # 1. ํ•ด์‰ฌ ๊ฒ€์ฆ ๋„๊ตฌ ์ƒ์„ฑ + password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) + # 2. ๊ฒ€์ฆ ์ˆ˜ํ–‰ ๋ฐ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ (true/false) + return password_hash.verify(plain_password, hashed_password) + +def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: + """ + JWT๋ฅผ ์ƒ์„ฑํ•ด ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + Args: + data (dict): JWT์— ํฌํ•จํ•  ํŽ˜์ด๋กœ๋“œ ์ •๋ณด. + expires_delta (Union[timedelta, None], optional): ๋งŒ๋ฃŒ ์‹œ๊ฐ„. ์ง€์ • ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + + Returns: + str: ์ธ์ฝ”๋”ฉ๋œ JWT ๋ฌธ์ž์—ด. + """ + to_encode = data.copy() + now = datetime.now(timezone.utc) + if expires_delta: + expire = now + expires_delta + else: + expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_token(token: str) -> dict[str, Any]: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + +if __name__ == "__main__": + password = "dhrdmstn" + hashed_password = hash_password(password) + print("Hashed Password:", hashed_password) + + # ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ + print("Verification:", verify_password(password, hashed_password)) diff --git a/appserver/apps/calendar/endpoints.py b/appserver/apps/calendar/endpoints.py new file mode 100644 index 0000000..475077b --- /dev/null +++ b/appserver/apps/calendar/endpoints.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, status +from sqlmodel import select + +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.apps.account.deps import CurrentUserOptionalDep +from db import DbSessionDep + +from .exceptions import CalendarNotFoundError, HostNotFoundError +from .schemas import CalendarDetailOut, CalendarOut + +router = APIRouter() + + +@router.get("/calendar/{host_username}", status_code=status.HTTP_200_OK) +async def host_calendar_detail( + host_username: str, + user: CurrentUserOptionalDep, + session: DbSessionDep +) -> CalendarOut | CalendarDetailOut: + stmt = select(User).where(User.username == host_username) + result = await session.execute(stmt) + host = result.scalar_one_or_none() + if host is None: + raise HostNotFoundError() + + stmt = select(Calendar).where(Calendar.host_id == host.id) + result = await session.execute(stmt) + calendar = result.scalar_one_or_none() + if calendar is None: + raise CalendarNotFoundError() + + if user is not None and user.id == host.id: + return CalendarDetailOut.model_validate(calendar) + diff --git a/appserver/apps/calendar/exceptions.py b/appserver/apps/calendar/exceptions.py new file mode 100644 index 0000000..5b7bfe9 --- /dev/null +++ b/appserver/apps/calendar/exceptions.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException, status + + +class HostNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="ํ˜ธ์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", + ) + + +class CalendarNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="์บ˜๋ฆฐ๋”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", + ) diff --git a/appserver/apps/calendar/models.py b/appserver/apps/calendar/models.py index 1bbe180..7545427 100644 --- a/appserver/apps/calendar/models.py +++ b/appserver/apps/calendar/models.py @@ -1,124 +1,124 @@ -from datetime import date, time, timezone, datetime -from typing import TYPE_CHECKING -from pydantic import AwareDatetime -from sqlalchemy_utc import UtcDateTime -from sqlmodel import SQLModel, Field, Relationship, Text, JSON, func, String, Column -from sqlalchemy.dialects.postgresql import JSONB -if TYPE_CHECKING: - from appserver.apps.account.models import User -# TYPE_CHECKING์€ ์ฝ”๋“œ๊ฐ€ ์‹คํ–‰๋  ๋•Œ๋Š” ๋ฌด์‹œํ•˜๊ณ , ํƒ€์ž… ๊ฒ€์‚ฌ(IDE ๋“ฑ) ์‹œ์—๋งŒ ์‚ฌ์šฉ๋จ -# User์™€ Calendar๊ฐ€ ์„œ๋กœ๋ฅผ importํ•˜๋ฉฐ ๋ฐœ์ƒํ•˜๋Š” ์ˆœํ™” ์ฐธ์กฐ ์—๋Ÿฌ ๋ฐฉ์ง€ - -class Calendar(SQLModel, table=True): - __tablename__ = "calendars" - - id: int = Field(default=None, primary_key=True) - # list[str]: ["FastAPI", "Python", "SQLModel"] - # DB ์ €์žฅ ์‹œ ๋ฌธ์ž์—ด '["A", "B"] JSON ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. - topics: list[str] = Field( - sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), - default_factory=list, - description="๊ฒŒ์ŠคํŠธ์™€ ๋‚˜๋ˆŒ ์ฃผ์ œ๋“ค") - # PostgreSQL JSONB ์ž๋ฃŒํ˜•, ํŠน๋ณ„ํ•œ ์ด์œ  ์—†์œผ๋ฉด JSONB ์‚ฌ์šฉ ๊ถŒ์žฅ - # JSONB: ๋ฐ”์ด๋„ˆ๋ฆฌ ํ˜•์‹, ๊ณต๋ฐฑ์ œ๊ฑฐ, ํ‚ค ์ˆœ์„œ ๋ณ€๊ฒฝ, ๋น ๋ฆ„, ์ธ๋ฑ์‹ฑ ์ง€์›, ์“ฐ๊ธฐ๋น„์šฉ ์ƒ๋Œ€์ ์œผ๋กœ ๋†’์Œ - # ์ฝ๊ธฐ ๋น„์šฉ ๋‚ฎ์Œ, ๊ณต๊ฐ„ ํšจ์œจ์„ฑ ๋†’์Œ, ์ค‘๋ณตํ‚ค: ๋งˆ์ง€๋ง‰ ํ‚ค๋กœ ๋Œ€์ฒด, ๋น ๋ฅธ ์งˆ์˜ ๋ฐ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•  ๋•Œ - # ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ํ‘œ์ค€ JSON ํƒ€์ž…์„ ์“ฐ๋˜, DB๊ฐ€ PostgresSQL์ผ ๊ฒฝ์šฐ์—๋งŒ - # ๋” ๋น ๋ฅด๊ณ  ๊ฒ€์ƒ‰์— ์œ ๋ฆฌํ•œ JSONB ํ˜•์‹์„ ์‚ฌ์šฉํ•˜๋„๋ก ์œ ์—ฐํ•˜๊ฒŒ ์„ค๊ณ„ํ•จ. - - description: str = Field(sa_type=Text, description="๊ฒŒ์ŠคํŠธ์—๊ฒŒ ๋ณด์—ฌ ์ค„ ์„ค๋ช…") - google_calendar_id: str = Field(max_length=1024, description="Google Calandar") - - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - }, - ) - - # 1:1 ๊ด€๊ณ„ ํ•œ ๋ช…์˜ ํ˜ธ์ŠคํŠธ(User)๋Š” ํ•˜๋‚˜์˜ ์บ˜๋ฆฐ๋”๋งŒ ๊ฐ€์ง - host_id: int = Field(foreign_key="users.id", unique=True) - host: "User" = Relationship( - back_populates="calendar", # ์ด ๊ฐ์ฒด๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๋ถ€๋ชจ์—๊ฒŒ๋งŒ ์ข…์†๋จ์„ ๋ช…์‹œ - sa_relationship_kwargs={"uselist": False, "single_parent": True}, - ) - # ๋ถ€๋ชจ๊ฐ€ ์‚ญ์ œ๋˜๊ฑฐ๋‚˜ ๊ด€๊ณ„๊ฐ€ ๋Š๊ธฐ๋ฉด ์ž์‹ ๊ฐ์ฒด๋„ ์‚ญ์ œํ•จ - # ์œ ์ €๊ฐ€ ํƒˆํ‡ดํ•˜๋ฉด ๊ทธ ์œ ์ €์˜ ๊ฐœ์ธ ์บ˜๋ฆฐ๋” ์„ค์ •๋„ ํ•จ๊ผ ์ง€์›Œ์ ธ์•ผ ํ•˜๋ฏ€๋กœ ์ด ์„ค์ •์„ ์ถ”๊ฐ€ - - time_slots: list["TimeSlot"] = Relationship(back_populates="calendar") - - -class TimeSlot(SQLModel, table=True): - __tablename__ = "time_slots" - - id: int = Field(default=None, primary_key=True) - start_time: time # ์˜ˆ์•ฝ ๊ฐ€๋Šฅํ•œ ์‹œ์ž‘ ์‹œ๊ฐ„ - end_time: time # ์˜ˆ์•ฝ ๊ฐ€๋Šฅ ์ข…๋ฃŒ ์‹œ๊ฐ„ - # ์š”์ผ ์„ค์ • 0(์›”) ~ 6(์ผ) ์ˆซ์ž๋ฅผ ๋ฆฌ์ŠคํŠธ๋กœ ์ €์žฅํ•˜์—ฌ ๋‹ค์ค‘ ์š”์ผ ์„ ํƒ ๊ธฐ๋Šฅ - weekdays: list[int] = Field( - sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), - description="์˜ˆ์•ฝ ๊ฐ€๋Šฅํ•œ ์š”์ผ๋“ค" - ) - - calendar_id: int = Field(foreign_key="calendars.id") - calendar: Calendar = Relationship(back_populates="time_slots") - bookings: list["Booking"] = Relationship(back_populates="time_slot") - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - }, - ) - -class Booking(SQLModel, table=True): - __tablename__ = "bookings" - - id: int = Field(default=None, primary_key=True) - when: date - topic: str - description: str = Field(sa_type=Text, description="์˜ˆ์•ฝ ์„ค๋ช…") - - time_slot_id: int = Field(foreign_key="time_slots.id") - time_slot: TimeSlot = Relationship(back_populates="bookings") - - guest_id: int = Field(foreign_key="users.id") - guest: "User" = Relationship(back_populates="bookings") - - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - }, +from datetime import date, time, timezone, datetime +from typing import TYPE_CHECKING +from pydantic import AwareDatetime +from sqlalchemy_utc import UtcDateTime +from sqlmodel import SQLModel, Field, Relationship, Text, JSON, func, String, Column +from sqlalchemy.dialects.postgresql import JSONB +if TYPE_CHECKING: + from appserver.apps.account.models import User +# TYPE_CHECKING์€ ์ฝ”๋“œ๊ฐ€ ์‹คํ–‰๋  ๋•Œ๋Š” ๋ฌด์‹œํ•˜๊ณ , ํƒ€์ž… ๊ฒ€์‚ฌ(IDE ๋“ฑ) ์‹œ์—๋งŒ ์‚ฌ์šฉ๋จ +# User์™€ Calendar๊ฐ€ ์„œ๋กœ๋ฅผ importํ•˜๋ฉฐ ๋ฐœ์ƒํ•˜๋Š” ์ˆœํ™” ์ฐธ์กฐ ์—๋Ÿฌ ๋ฐฉ์ง€ + +class Calendar(SQLModel, table=True): + __tablename__ = "calendars" + + id: int = Field(default=None, primary_key=True) + # list[str]: ["FastAPI", "Python", "SQLModel"] + # DB ์ €์žฅ ์‹œ ๋ฌธ์ž์—ด '["A", "B"] JSON ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. + topics: list[str] = Field( + sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), + default_factory=list, + description="๊ฒŒ์ŠคํŠธ์™€ ๋‚˜๋ˆŒ ์ฃผ์ œ๋“ค") + # PostgreSQL JSONB ์ž๋ฃŒํ˜•, ํŠน๋ณ„ํ•œ ์ด์œ  ์—†์œผ๋ฉด JSONB ์‚ฌ์šฉ ๊ถŒ์žฅ + # JSONB: ๋ฐ”์ด๋„ˆ๋ฆฌ ํ˜•์‹, ๊ณต๋ฐฑ์ œ๊ฑฐ, ํ‚ค ์ˆœ์„œ ๋ณ€๊ฒฝ, ๋น ๋ฆ„, ์ธ๋ฑ์‹ฑ ์ง€์›, ์“ฐ๊ธฐ๋น„์šฉ ์ƒ๋Œ€์ ์œผ๋กœ ๋†’์Œ + # ์ฝ๊ธฐ ๋น„์šฉ ๋‚ฎ์Œ, ๊ณต๊ฐ„ ํšจ์œจ์„ฑ ๋†’์Œ, ์ค‘๋ณตํ‚ค: ๋งˆ์ง€๋ง‰ ํ‚ค๋กœ ๋Œ€์ฒด, ๋น ๋ฅธ ์งˆ์˜ ๋ฐ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•  ๋•Œ + # ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ํ‘œ์ค€ JSON ํƒ€์ž…์„ ์“ฐ๋˜, DB๊ฐ€ PostgresSQL์ผ ๊ฒฝ์šฐ์—๋งŒ + # ๋” ๋น ๋ฅด๊ณ  ๊ฒ€์ƒ‰์— ์œ ๋ฆฌํ•œ JSONB ํ˜•์‹์„ ์‚ฌ์šฉํ•˜๋„๋ก ์œ ์—ฐํ•˜๊ฒŒ ์„ค๊ณ„ํ•จ. + + description: str = Field(sa_type=Text, description="๊ฒŒ์ŠคํŠธ์—๊ฒŒ ๋ณด์—ฌ ์ค„ ์„ค๋ช…") + google_calendar_id: str = Field(max_length=1024, description="Google Calandar") + + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + }, + ) + + # 1:1 ๊ด€๊ณ„ ํ•œ ๋ช…์˜ ํ˜ธ์ŠคํŠธ(User)๋Š” ํ•˜๋‚˜์˜ ์บ˜๋ฆฐ๋”๋งŒ ๊ฐ€์ง + host_id: int = Field(foreign_key="users.id", unique=True) + host: "User" = Relationship( + back_populates="calendar", # ์ด ๊ฐ์ฒด๋Š” ๋‹จ ํ•˜๋‚˜์˜ ๋ถ€๋ชจ์—๊ฒŒ๋งŒ ์ข…์†๋จ์„ ๋ช…์‹œ + sa_relationship_kwargs={"uselist": False, "single_parent": True}, + ) + # ๋ถ€๋ชจ๊ฐ€ ์‚ญ์ œ๋˜๊ฑฐ๋‚˜ ๊ด€๊ณ„๊ฐ€ ๋Š๊ธฐ๋ฉด ์ž์‹ ๊ฐ์ฒด๋„ ์‚ญ์ œํ•จ + # ์œ ์ €๊ฐ€ ํƒˆํ‡ดํ•˜๋ฉด ๊ทธ ์œ ์ €์˜ ๊ฐœ์ธ ์บ˜๋ฆฐ๋” ์„ค์ •๋„ ํ•จ๊ผ ์ง€์›Œ์ ธ์•ผ ํ•˜๋ฏ€๋กœ ์ด ์„ค์ •์„ ์ถ”๊ฐ€ + + time_slots: list["TimeSlot"] = Relationship(back_populates="calendar") + + +class TimeSlot(SQLModel, table=True): + __tablename__ = "time_slots" + + id: int = Field(default=None, primary_key=True) + start_time: time # ์˜ˆ์•ฝ ๊ฐ€๋Šฅํ•œ ์‹œ์ž‘ ์‹œ๊ฐ„ + end_time: time # ์˜ˆ์•ฝ ๊ฐ€๋Šฅ ์ข…๋ฃŒ ์‹œ๊ฐ„ + # ์š”์ผ ์„ค์ • 0(์›”) ~ 6(์ผ) ์ˆซ์ž๋ฅผ ๋ฆฌ์ŠคํŠธ๋กœ ์ €์žฅํ•˜์—ฌ ๋‹ค์ค‘ ์š”์ผ ์„ ํƒ ๊ธฐ๋Šฅ + weekdays: list[int] = Field( + sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), + description="์˜ˆ์•ฝ ๊ฐ€๋Šฅํ•œ ์š”์ผ๋“ค" + ) + + calendar_id: int = Field(foreign_key="calendars.id") + calendar: Calendar = Relationship(back_populates="time_slots") + bookings: list["Booking"] = Relationship(back_populates="time_slot") + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + }, + ) + +class Booking(SQLModel, table=True): + __tablename__ = "bookings" + + id: int = Field(default=None, primary_key=True) + when: date + topic: str + description: str = Field(sa_type=Text, description="์˜ˆ์•ฝ ์„ค๋ช…") + + time_slot_id: int = Field(foreign_key="time_slots.id") + time_slot: TimeSlot = Relationship(back_populates="bookings") + + guest_id: int = Field(foreign_key="users.id") + guest: "User" = Relationship(back_populates="bookings") + + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + }, ) \ No newline at end of file diff --git a/appserver/apps/calendar/schemas.py b/appserver/apps/calendar/schemas.py new file mode 100644 index 0000000..1d74953 --- /dev/null +++ b/appserver/apps/calendar/schemas.py @@ -0,0 +1,13 @@ +from pydantic import AwareDatetime +from sqlmodel import SQLModel + +class CalendarOut(SQLModel): + topics: list[str] + description: str + +class CalendarDetailOut(CalendarOut): + host_id: int + google_calendar_id: str + created_at: AwareDatetime + updated_at: AwareDatetime + diff --git a/appserver/db.py b/appserver/db.py index 0e0716c..3c57831 100644 --- a/appserver/db.py +++ b/appserver/db.py @@ -1,52 +1,52 @@ -from sqlalchemy.ext.asyncio import ( - create_async_engine, - async_sessionmaker, - AsyncSession, - AsyncEngine, -) -from typing import Annotated # ํƒ€์ž… ํžŒํŠธ์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๋„๊ตฌ -from fastapi import Depends # ์˜์กด์„ฑ ์ฃผ์ž…์„ ์„ ์–ธํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. - -# 1. Engine ์ƒ์„ฑ: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋กœ ๊ฐ€๋Š” '๊ณ ์†๋„๋กœ'๋ฅผ ๊ฑด์„คํ•˜๋Š” ํ•จ์ˆ˜ -def create_engine(dsn: str): - # create_async_engine: ๋น„๋™๊ธฐ ๋ฐฉ์‹์œผ๋กœ DB์— ์ ‘์†ํ•˜๋Š” ์—”์ง„์„ ๋งŒ๋“ฆ - # dsn: Database Source Name (DB ์ฃผ์†Œ. ์˜ˆ: postgresql+asyncpg://...) - # echo=True: ์‹คํ–‰๋˜๋Š” ๋ชจ๋“  SQL ์ฟผ๋ฆฌ๋ฅผ ํ„ฐ๋ฏธ๋„์— ์ถœ๋ ฅ (๋””๋ฒ„๊น…์šฉ ํ•„์ˆ˜ ์„ค์ •) - return create_async_engine( - dsn, - echo=True, - ) - -# 2. Session Factory ์ƒ์„ฑ: DB์™€ ๋Œ€ํ™”ํ•  ์ผ๊พผ(Session)์„ ์ฐ์–ด๋‚ด๋Š” ๊ณต์žฅ -def create_session(async_engine: AsyncEngine | None = None): - # ๋งŒ์•ฝ ์—”์ง„์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜๋‹ค๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ (๋ณดํ†ต์€ ๋ฏธ๋ฆฌ ๋งŒ๋“  ์—”์ง„์„ ์žฌ์‚ฌ์šฉํ•จ) - if async_engine is None: - async_engine = create_engine() - - # async_sessionmaker: ์„ธ์…˜์„ ๊ด€๋ฆฌํ•˜๊ธฐ ์‰ฝ๊ฒŒ ๋งŒ๋“ค์–ด์ฃผ๋Š” ํŒฉํ† ๋ฆฌ ํด๋ž˜์Šค - return async_sessionmaker( - async_engine, - expire_on_commit=False, # commit ํ›„์—๋„ ๊ฐ์ฒด์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฉ”๋ชจ๋ฆฌ์— ์œ ์ง€ (๋น„๋™๊ธฐ ํšจ์œจ์„ฑ) - autoflush=False, # ์˜๋„ํ•˜์ง€ ์•Š์€ ์‹œ์ ์— DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ˜์˜๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ - class_=AsyncSession, # ์ด ํŒฉํ† ๋ฆฌ๊ฐ€ ์ฐ์–ด๋‚ผ ์ผ๊พผ์˜ ํƒ€์ž…์€ ๋น„๋™๊ธฐ ์„ธ์…˜์ž„์„ ๋ช…์‹œ - ) - -# 3. Dependency Injection์šฉ ํ•จ์ˆ˜: API ์š”์ฒญ์ด ๋“ค์–ด์˜ฌ ๋•Œ๋งˆ๋‹ค ์„ธ์…˜์„ ๋นŒ๋ ค์ฃผ๋Š” ์—ญํ•  -async def use_session(): - # async with: ์ž‘์—…์ด ๋๋‚˜๋ฉด ์ž๋™์œผ๋กœ ์„ธ์…˜์„ ๋‹ซ์•„์คŒ (์•ˆ์ „ํ•œ ์ž์› ๊ด€๋ฆฌ) - async with async_session_factory() as session: - yield session # FastAPI์˜ Depends()๋ฅผ ํ†ตํ•ด ์„ธ์…˜์„ API ํ•ธ๋“ค๋Ÿฌ๋กœ ์ „๋‹ฌ - -# --- ์‹ค์ œ ์‹คํ–‰ ๋ฐ ์ดˆ๊ธฐํ™” --- - -DSN = "sqlite+aiosqlite:///./local.db" -# ํ”„๋กœ์ ํŠธ ์ „์—ญ์—์„œ ์‚ฌ์šฉํ•  ๋‹จ ํ•˜๋‚˜์˜ ์—”์ง„ -engine = create_engine(DSN) - -# ํ”„๋กœ์ ํŠธ ์ „์—ญ์—์„œ ์ƒ์šฉํ•  ์„ธ์…˜ ๊ณต์žฅ -async_session_factory = create_session(engine) - -# ์˜์กด์„ฑ ์ฃผ์ž…์„ ์œ„ํ•œ ๋น„๋™๊ธฐ DB ์„ธ์…˜ ํƒ€์ž… ๋ณ„์นญ (Annotated์™€ Depends ์กฐํ•ฉ) -# DBSessionDep์€ ๋น„๋™๊ธฐ DB ์„ธ์…˜ ํƒ€์ž…์ด๋ฉด์„œ, use_session ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด -# ์ž๋™์œผ๋กœ ๋นŒ๋ ค์˜ค๊ฒ ๋‹ค๋Š” ์„ ์–ธ +from sqlalchemy.ext.asyncio import ( + create_async_engine, + async_sessionmaker, + AsyncSession, + AsyncEngine, +) +from typing import Annotated # ํƒ€์ž… ํžŒํŠธ์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๋„๊ตฌ +from fastapi import Depends # ์˜์กด์„ฑ ์ฃผ์ž…์„ ์„ ์–ธํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. + +# 1. Engine ์ƒ์„ฑ: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋กœ ๊ฐ€๋Š” '๊ณ ์†๋„๋กœ'๋ฅผ ๊ฑด์„คํ•˜๋Š” ํ•จ์ˆ˜ +def create_engine(dsn: str): + # create_async_engine: ๋น„๋™๊ธฐ ๋ฐฉ์‹์œผ๋กœ DB์— ์ ‘์†ํ•˜๋Š” ์—”์ง„์„ ๋งŒ๋“ฆ + # dsn: Database Source Name (DB ์ฃผ์†Œ. ์˜ˆ: postgresql+asyncpg://...) + # echo=True: ์‹คํ–‰๋˜๋Š” ๋ชจ๋“  SQL ์ฟผ๋ฆฌ๋ฅผ ํ„ฐ๋ฏธ๋„์— ์ถœ๋ ฅ (๋””๋ฒ„๊น…์šฉ ํ•„์ˆ˜ ์„ค์ •) + return create_async_engine( + dsn, + echo=True, + ) + +# 2. Session Factory ์ƒ์„ฑ: DB์™€ ๋Œ€ํ™”ํ•  ์ผ๊พผ(Session)์„ ์ฐ์–ด๋‚ด๋Š” ๊ณต์žฅ +def create_session(async_engine: AsyncEngine | None = None): + # ๋งŒ์•ฝ ์—”์ง„์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜๋‹ค๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ (๋ณดํ†ต์€ ๋ฏธ๋ฆฌ ๋งŒ๋“  ์—”์ง„์„ ์žฌ์‚ฌ์šฉํ•จ) + if async_engine is None: + async_engine = create_engine() + + # async_sessionmaker: ์„ธ์…˜์„ ๊ด€๋ฆฌํ•˜๊ธฐ ์‰ฝ๊ฒŒ ๋งŒ๋“ค์–ด์ฃผ๋Š” ํŒฉํ† ๋ฆฌ ํด๋ž˜์Šค + return async_sessionmaker( + async_engine, + expire_on_commit=False, # commit ํ›„์—๋„ ๊ฐ์ฒด์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฉ”๋ชจ๋ฆฌ์— ์œ ์ง€ (๋น„๋™๊ธฐ ํšจ์œจ์„ฑ) + autoflush=False, # ์˜๋„ํ•˜์ง€ ์•Š์€ ์‹œ์ ์— DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ˜์˜๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ + class_=AsyncSession, # ์ด ํŒฉํ† ๋ฆฌ๊ฐ€ ์ฐ์–ด๋‚ผ ์ผ๊พผ์˜ ํƒ€์ž…์€ ๋น„๋™๊ธฐ ์„ธ์…˜์ž„์„ ๋ช…์‹œ + ) + +# 3. Dependency Injection์šฉ ํ•จ์ˆ˜: API ์š”์ฒญ์ด ๋“ค์–ด์˜ฌ ๋•Œ๋งˆ๋‹ค ์„ธ์…˜์„ ๋นŒ๋ ค์ฃผ๋Š” ์—ญํ•  +async def use_session(): + # async with: ์ž‘์—…์ด ๋๋‚˜๋ฉด ์ž๋™์œผ๋กœ ์„ธ์…˜์„ ๋‹ซ์•„์คŒ (์•ˆ์ „ํ•œ ์ž์› ๊ด€๋ฆฌ) + async with async_session_factory() as session: + yield session # FastAPI์˜ Depends()๋ฅผ ํ†ตํ•ด ์„ธ์…˜์„ API ํ•ธ๋“ค๋Ÿฌ๋กœ ์ „๋‹ฌ + +# --- ์‹ค์ œ ์‹คํ–‰ ๋ฐ ์ดˆ๊ธฐํ™” --- + +DSN = "sqlite+aiosqlite:///./local.db" +# ํ”„๋กœ์ ํŠธ ์ „์—ญ์—์„œ ์‚ฌ์šฉํ•  ๋‹จ ํ•˜๋‚˜์˜ ์—”์ง„ +engine = create_engine(DSN) + +# ํ”„๋กœ์ ํŠธ ์ „์—ญ์—์„œ ์ƒ์šฉํ•  ์„ธ์…˜ ๊ณต์žฅ +async_session_factory = create_session(engine) + +# ์˜์กด์„ฑ ์ฃผ์ž…์„ ์œ„ํ•œ ๋น„๋™๊ธฐ DB ์„ธ์…˜ ํƒ€์ž… ๋ณ„์นญ (Annotated์™€ Depends ์กฐํ•ฉ) +# DBSessionDep์€ ๋น„๋™๊ธฐ DB ์„ธ์…˜ ํƒ€์ž…์ด๋ฉด์„œ, use_session ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด +# ์ž๋™์œผ๋กœ ๋นŒ๋ ค์˜ค๊ฒ ๋‹ค๋Š” ์„ ์–ธ DbSessionDep = Annotated[AsyncSession, Depends(use_session)] \ No newline at end of file diff --git a/appserver/libs/datetime/calendar.py b/appserver/libs/datetime/calendar.py index 4a7e5ed..ea58c2e 100644 --- a/appserver/libs/datetime/calendar.py +++ b/appserver/libs/datetime/calendar.py @@ -1,94 +1,94 @@ -from datetime import date, timedelta - -def get_start_weekday_of_month(year:int, month:int) -> int: - """ - ํŠน์ • ์—ฐ๋„์™€ ์›”์˜ 1์ผ์ด ๋ฌด์Šจ ์š”์ผ์ธ์ง€ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. - - Args: - year (int): ๊ณ„์‚ฐํ•˜๋ ค๋Š” ์—ฐ๋„ (์˜ˆ: 2024) - month (int): ๊ณ„์‚ฐํ•˜๋ ค๋Š” ์›” (1~12) - - Returns: - int: ์š”์ผ์„ ๋‚˜ํƒ€๋‚ด๋Š” ์ •์ˆ˜ (0: ์›”์š”์ผ, 1: ํ™”์š”์ผ, ..., 6: ์ผ์š”์ผ) - - >>> get_start_weekday_of_month(2024, 12) - 6 - >>> get_start_weekday_of_month(2025, 2) - 5 - """ - result = date(year, month, 1) - return result.weekday() - -def get_last_day_of_month(year: int, month: int) -> int: - """ํŠน์ • ์—ฐ๋„์™€ ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์งœ(์ผ)๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. - - Args: - year (int): ์กฐํšŒํ•˜๊ณ ์ž ํ•˜๋Š” ์—ฐ๋„. - month (int): ์กฐํšŒํ•˜๊ณ ์ž ํ•˜๋Š” ์›” (1~12). - - Returns: - int: ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์งœ (28, 29, 30, ๋˜๋Š” 31). - - # 2. Docstring ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ˆ˜์ • - >>> get_last_day_of_month(2024, 2) # ์œค๋…„ - 29 - >>> get_last_day_of_month(2025, 2) # ํ‰๋…„ - 28 - """ - # [๋กœ์ง ์„ค๋ช…] ๋‹ค์Œ ๋‹ฌ์˜ 1์ผ์„ ๊ตฌํ•œ ๋’ค ํ•˜๋ฃจ๋ฅผ ๋นผ๋ฉด ์ด๋ฒˆ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์ด ๋ฉ๋‹ˆ๋‹ค. - if month == 12: - # 12์›”์ธ ๊ฒฝ์šฐ ๋‹ค์Œ ํ•ด 1์›” 1์ผ์„ ๊ธฐ์ค€์ ์œผ๋กœ ์žก์Œ - next_month = date(year + 1, 1, 1) - else: - # ๊ทธ ์™ธ์—๋Š” ๋‹ค์Œ ๋‹ฌ 1์ผ์„ ๊ธฐ์ค€์ ์œผ๋กœ ์žก์Œ - next_month = date(year, month + 1, 1) - - # [ํ•ต์‹ฌ] timedelta(days=1)์„ ๋นผ์„œ ์ด๋ฒˆ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚  ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑ - result = next_month - timedelta(days=1) - - return result.day - -def get_range_days_of_month(year:int, month:int): - """ํŠน์ • ์›”์˜ ๋‹ฌ๋ ฅ ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ๋‚ ์งœ ๋ฆฌ์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. - - 1์ผ์ด ์‹œ์ž‘๋˜๊ธฐ ์ „์˜ ๋นˆ์นธ์€ 0์œผ๋กœ ์ฑ„์›Œ์ง€๋ฉฐ, - ์ผ์š”์ผ(0)๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” ๋‹ฌ๋ ฅ ํ•œ ๋‹ฌ ์น˜ ๋ฐ์ดํ„ฐ์˜ ๊ธฐ๋ฐ˜์ด ๋ฉ๋‹ˆ๋‹ค. - - Args: - year (int): ์กฐํšŒํ•  ์—ฐ๋„. - month (int): ์กฐํšŒํ•  ์›” (1~12). - - Returns: - list[int]: 0(๋นˆ์นธ)๊ณผ ์‹ค์ œ ๋‚ ์งœ(1~๋งˆ์ง€๋ง‰๋‚ )๊ฐ€ ์„ž์ธ ์ •์ˆ˜ ๋ฆฌ์ŠคํŠธ. - >>> result = get_range_days_of_month(2024, 3) - >>> result[:5] - [0, 0, 0, 0, 0] - >>> result[5] - 1 - >>> len(result) - 36 - >>> result = get_range_days_of_month(2024, 2) # ์œค๋…„ - >>> result[:4] - [0, 0, 0, 0] - >>> result[4] - 1 - >>> len(result) - 33 - """ - # ์›”์˜ ์‹œ์ž‘ ์š”์ผ์„ ๊ฐ€์ ธ์˜ด(์›”์š”์ผ=0~์ผ์š”์ผ=6) - start_weekday = get_start_weekday_of_month(year, month) - - # ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์งœ๋ฅผ ๊ฐ€์ ธ์˜ด - last_day = get_last_day_of_month(year, month) - - # ์›”์š”์ผ = 0์„ ์›”์š”์ผ=1๋กœ ๋ณ€ํ™˜(์ผ์š”์ผ=0์œผ๋กœ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด) - start_weekday = (start_weekday + 1) % 7 - - # ๊ฒฐ๊ณผ ๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ - result = [0] * start_weekday # ์‹œ์ž‘ ์š”์ผ ์ „๊นŒ์ง€ 0์œผ๋กœ ์ฑ„์›€ - - # 1์ผ๋ถ€ํ„ฐ ๋งˆ์ง€๋ง‰ ๋‚ ๊นŒ์ง€ ์ถ”๊ฐ€ - # for day in range(1, last_day + 1): - # result.append(day) - +from datetime import date, timedelta + +def get_start_weekday_of_month(year:int, month:int) -> int: + """ + ํŠน์ • ์—ฐ๋„์™€ ์›”์˜ 1์ผ์ด ๋ฌด์Šจ ์š”์ผ์ธ์ง€ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + + Args: + year (int): ๊ณ„์‚ฐํ•˜๋ ค๋Š” ์—ฐ๋„ (์˜ˆ: 2024) + month (int): ๊ณ„์‚ฐํ•˜๋ ค๋Š” ์›” (1~12) + + Returns: + int: ์š”์ผ์„ ๋‚˜ํƒ€๋‚ด๋Š” ์ •์ˆ˜ (0: ์›”์š”์ผ, 1: ํ™”์š”์ผ, ..., 6: ์ผ์š”์ผ) + + >>> get_start_weekday_of_month(2024, 12) + 6 + >>> get_start_weekday_of_month(2025, 2) + 5 + """ + result = date(year, month, 1) + return result.weekday() + +def get_last_day_of_month(year: int, month: int) -> int: + """ํŠน์ • ์—ฐ๋„์™€ ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์งœ(์ผ)๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + Args: + year (int): ์กฐํšŒํ•˜๊ณ ์ž ํ•˜๋Š” ์—ฐ๋„. + month (int): ์กฐํšŒํ•˜๊ณ ์ž ํ•˜๋Š” ์›” (1~12). + + Returns: + int: ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์งœ (28, 29, 30, ๋˜๋Š” 31). + + # 2. Docstring ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ˆ˜์ • + >>> get_last_day_of_month(2024, 2) # ์œค๋…„ + 29 + >>> get_last_day_of_month(2025, 2) # ํ‰๋…„ + 28 + """ + # [๋กœ์ง ์„ค๋ช…] ๋‹ค์Œ ๋‹ฌ์˜ 1์ผ์„ ๊ตฌํ•œ ๋’ค ํ•˜๋ฃจ๋ฅผ ๋นผ๋ฉด ์ด๋ฒˆ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์ด ๋ฉ๋‹ˆ๋‹ค. + if month == 12: + # 12์›”์ธ ๊ฒฝ์šฐ ๋‹ค์Œ ํ•ด 1์›” 1์ผ์„ ๊ธฐ์ค€์ ์œผ๋กœ ์žก์Œ + next_month = date(year + 1, 1, 1) + else: + # ๊ทธ ์™ธ์—๋Š” ๋‹ค์Œ ๋‹ฌ 1์ผ์„ ๊ธฐ์ค€์ ์œผ๋กœ ์žก์Œ + next_month = date(year, month + 1, 1) + + # [ํ•ต์‹ฌ] timedelta(days=1)์„ ๋นผ์„œ ์ด๋ฒˆ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚  ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑ + result = next_month - timedelta(days=1) + + return result.day + +def get_range_days_of_month(year:int, month:int): + """ํŠน์ • ์›”์˜ ๋‹ฌ๋ ฅ ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ๋‚ ์งœ ๋ฆฌ์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + + 1์ผ์ด ์‹œ์ž‘๋˜๊ธฐ ์ „์˜ ๋นˆ์นธ์€ 0์œผ๋กœ ์ฑ„์›Œ์ง€๋ฉฐ, + ์ผ์š”์ผ(0)๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๋Š” ๋‹ฌ๋ ฅ ํ•œ ๋‹ฌ ์น˜ ๋ฐ์ดํ„ฐ์˜ ๊ธฐ๋ฐ˜์ด ๋ฉ๋‹ˆ๋‹ค. + + Args: + year (int): ์กฐํšŒํ•  ์—ฐ๋„. + month (int): ์กฐํšŒํ•  ์›” (1~12). + + Returns: + list[int]: 0(๋นˆ์นธ)๊ณผ ์‹ค์ œ ๋‚ ์งœ(1~๋งˆ์ง€๋ง‰๋‚ )๊ฐ€ ์„ž์ธ ์ •์ˆ˜ ๋ฆฌ์ŠคํŠธ. + >>> result = get_range_days_of_month(2024, 3) + >>> result[:5] + [0, 0, 0, 0, 0] + >>> result[5] + 1 + >>> len(result) + 36 + >>> result = get_range_days_of_month(2024, 2) # ์œค๋…„ + >>> result[:4] + [0, 0, 0, 0] + >>> result[4] + 1 + >>> len(result) + 33 + """ + # ์›”์˜ ์‹œ์ž‘ ์š”์ผ์„ ๊ฐ€์ ธ์˜ด(์›”์š”์ผ=0~์ผ์š”์ผ=6) + start_weekday = get_start_weekday_of_month(year, month) + + # ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์งœ๋ฅผ ๊ฐ€์ ธ์˜ด + last_day = get_last_day_of_month(year, month) + + # ์›”์š”์ผ = 0์„ ์›”์š”์ผ=1๋กœ ๋ณ€ํ™˜(์ผ์š”์ผ=0์œผ๋กœ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด) + start_weekday = (start_weekday + 1) % 7 + + # ๊ฒฐ๊ณผ ๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ + result = [0] * start_weekday # ์‹œ์ž‘ ์š”์ผ ์ „๊นŒ์ง€ 0์œผ๋กœ ์ฑ„์›€ + + # 1์ผ๋ถ€ํ„ฐ ๋งˆ์ง€๋ง‰ ๋‚ ๊นŒ์ง€ ์ถ”๊ฐ€ + # for day in range(1, last_day + 1): + # result.append(day) + return result + list(range(1, last_day + 1)) \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 881952e..0c8fd69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,563 +1,924 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "aiosqlite" -version = "0.22.1" -description = "asyncio bridge to the standard sqlite3 module" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, - {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, -] - -[package.extras] -dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] -docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] - -[[package]] -name = "alembic" -version = "1.17.2" -description = "A database migration tool for SQLAlchemy." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6"}, - {file = "alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e"}, -] - -[package.dependencies] -Mako = "*" -SQLAlchemy = ">=1.4.0" -tomli = {version = "*", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.12" - -[package.extras] -tz = ["tzdata"] - -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "greenlet" -version = "3.3.0" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5"}, - {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9"}, - {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d"}, - {file = "greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082"}, - {file = "greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948"}, - {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794"}, - {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5"}, - {file = "greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71"}, - {file = "greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b"}, - {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53"}, - {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614"}, - {file = "greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39"}, - {file = "greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527"}, - {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39"}, - {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8"}, - {file = "greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38"}, - {file = "greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955"}, - {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55"}, - {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc"}, - {file = "greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170"}, - {file = "greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b"}, - {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd"}, - {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9"}, - {file = "greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil", "setuptools"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "mako" -version = "1.3.10" -description = "A super-fast templating language that borrows the best ideas from the existing templating languages." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, - {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, -] - -[package.dependencies] -MarkupSafe = ">=0.9.2" - -[package.extras] -babel = ["Babel"] -lingua = ["lingua"] -testing = ["pytest"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pytest" -version = "9.0.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, - {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, -] - -[package.dependencies] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "sqlalchemy" -version = "2.0.45" -description = "Database Abstraction Library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"}, - {file = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"}, - {file = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"}, - {file = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"}, - {file = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5964f832431b7cdfaaa22a660b4c7eb1dfcd6ed41375f67fd3e3440fd95cb3cc"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee580ab50e748208754ae8980cec79ec205983d8cf8b3f7c39067f3d9f2c8e22"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13e27397a7810163440c6bfed6b3fe46f1bfb2486eb540315a819abd2c004128"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ed3635353e55d28e7f4a95c8eda98a5cdc0a0b40b528433fbd41a9ae88f55b3d"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:db6834900338fb13a9123307f0c2cbb1f890a8656fcd5e5448ae3ad5bbe8d312"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-win32.whl", hash = "sha256:1d8b4a7a8c9b537509d56d5cd10ecdcfbb95912d72480c8861524efecc6a3fff"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl", hash = "sha256:ebd300afd2b62679203435f596b2601adafe546cb7282d5a0cd3ed99e423720f"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d29b2b99d527dbc66dd87c3c3248a5dd789d974a507f4653c969999fc7c1191b"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59a8b8bd9c6bedf81ad07c8bd5543eedca55fe9b8780b2b628d495ba55f8db1e"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd93c6f5d65f254ceabe97548c709e073d6da9883343adaa51bf1a913ce93f8e"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d0beadc2535157070c9c17ecf25ecec31e13c229a8f69196d7590bde8082bf1"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e057f928ffe9c9b246a55b469c133b98a426297e1772ad24ce9f0c47d123bd5b"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-win32.whl", hash = "sha256:c1c2091b1489435ff85728fafeb990f073e64f6f5e81d5cd53059773e8521eb6"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl", hash = "sha256:56ead1f8dfb91a54a28cd1d072c74b3d635bcffbd25e50786533b822d4f2cde2"}, - {file = "sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"}, - {file = "sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"}, -] - -[package.dependencies] -greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} -typing-extensions = ">=4.6.0" - -[package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] -aioodbc = ["aioodbc", "greenlet (>=1)"] -aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (>=1)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] -mssql = ["pyodbc"] -mssql-pymssql = ["pymssql"] -mssql-pyodbc = ["pyodbc"] -mypy = ["mypy (>=0.910)"] -mysql = ["mysqlclient (>=1.4.0)"] -mysql-connector = ["mysql-connector-python"] -oracle = ["cx_oracle (>=8)"] -oracle-oracledb = ["oracledb (>=1.0.1)"] -postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] -postgresql-pg8000 = ["pg8000 (>=1.29.1)"] -postgresql-psycopg = ["psycopg (>=3.0.7)"] -postgresql-psycopg2binary = ["psycopg2-binary"] -postgresql-psycopg2cffi = ["psycopg2cffi"] -postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] -pymysql = ["pymysql"] -sqlcipher = ["sqlcipher3_binary"] - -[[package]] -name = "sqlalchemy-utc" -version = "0.14.0" -description = "SQLAlchemy type to store aware datetime values" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "SQLAlchemy-Utc-0.14.0.tar.gz", hash = "sha256:8e041624595b66d7b1d5ea8b6de486df5c1b9352697f3b24f862f0ded56cd7aa"}, - {file = "SQLAlchemy_Utc-0.14.0-py2.py3-none-any.whl", hash = "sha256:d2379eed5cce372128b5e744ce382decd262b2c742ab31f7f22ca11c6647f60b"}, -] - -[package.dependencies] -setuptools = "*" -SQLAlchemy = ">=0.9.0" - -[[package]] -name = "tomli" -version = "2.3.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] -markers = {dev = "python_version < \"3.13\""} - -[metadata] -lock-version = "2.1" -python-versions = ">=3.10" -content-hash = "09a4308827555f8bf0c6e31442a33fe1c27c5c7fe686be9a55fbf498a214e929" +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "aiosqlite" +version = "0.22.1" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, + {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, +] + +[package.extras] +dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] + +[[package]] +name = "alembic" +version = "1.17.2" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6"}, + {file = "alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.4.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"}, + {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"}, + {file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.0.1", markers = "python_version < \"3.14\""}, + {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, + {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, + {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, + {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, + {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, + {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, + {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "greenlet" +version = "3.3.0" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5"}, + {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9"}, + {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d"}, + {file = "greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082"}, + {file = "greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948"}, + {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794"}, + {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5"}, + {file = "greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71"}, + {file = "greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b"}, + {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53"}, + {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614"}, + {file = "greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39"}, + {file = "greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527"}, + {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39"}, + {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8"}, + {file = "greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38"}, + {file = "greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955"}, + {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55"}, + {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc"}, + {file = "greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170"}, + {file = "greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b"}, + {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd"}, + {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9"}, + {file = "greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "mako" +version = "1.3.10" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, + {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pwdlib" +version = "0.3.0" +description = "Modern password hashing for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d"}, + {file = "pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc"}, +] + +[package.dependencies] +argon2-cffi = {version = ">=23.1.0,<26", optional = true, markers = "extra == \"argon2\""} +bcrypt = {version = ">=4.1.2,<6", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=23.1.0,<26)"] +bcrypt = ["bcrypt (>=4.1.2,<6)"] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-jose" +version = "3.5.0" +description = "JOSE implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, + {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = ">=0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "rsa" +version = "4.2" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +markers = "python_version < \"3.14\"" +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.45" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"}, + {file = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"}, + {file = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"}, + {file = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"}, + {file = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5964f832431b7cdfaaa22a660b4c7eb1dfcd6ed41375f67fd3e3440fd95cb3cc"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee580ab50e748208754ae8980cec79ec205983d8cf8b3f7c39067f3d9f2c8e22"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13e27397a7810163440c6bfed6b3fe46f1bfb2486eb540315a819abd2c004128"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ed3635353e55d28e7f4a95c8eda98a5cdc0a0b40b528433fbd41a9ae88f55b3d"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:db6834900338fb13a9123307f0c2cbb1f890a8656fcd5e5448ae3ad5bbe8d312"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-win32.whl", hash = "sha256:1d8b4a7a8c9b537509d56d5cd10ecdcfbb95912d72480c8861524efecc6a3fff"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl", hash = "sha256:ebd300afd2b62679203435f596b2601adafe546cb7282d5a0cd3ed99e423720f"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d29b2b99d527dbc66dd87c3c3248a5dd789d974a507f4653c969999fc7c1191b"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59a8b8bd9c6bedf81ad07c8bd5543eedca55fe9b8780b2b628d495ba55f8db1e"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd93c6f5d65f254ceabe97548c709e073d6da9883343adaa51bf1a913ce93f8e"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d0beadc2535157070c9c17ecf25ecec31e13c229a8f69196d7590bde8082bf1"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e057f928ffe9c9b246a55b469c133b98a426297e1772ad24ce9f0c47d123bd5b"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-win32.whl", hash = "sha256:c1c2091b1489435ff85728fafeb990f073e64f6f5e81d5cd53059773e8521eb6"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl", hash = "sha256:56ead1f8dfb91a54a28cd1d072c74b3d635bcffbd25e50786533b822d4f2cde2"}, + {file = "sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"}, + {file = "sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "sqlalchemy-utc" +version = "0.14.0" +description = "SQLAlchemy type to store aware datetime values" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "SQLAlchemy-Utc-0.14.0.tar.gz", hash = "sha256:8e041624595b66d7b1d5ea8b6de486df5c1b9352697f3b24f862f0ded56cd7aa"}, + {file = "SQLAlchemy_Utc-0.14.0-py2.py3-none-any.whl", hash = "sha256:d2379eed5cce372128b5e744ce382decd262b2c742ab31f7f22ca11c6647f60b"}, +] + +[package.dependencies] +setuptools = "*" +SQLAlchemy = ">=0.9.0" + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.14\""} + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "ac9fd150d50b493b299c784f589e65012ad1dff8e3dbb2269167e767014879e3" diff --git a/pyproject.toml b/pyproject.toml index 4d235e3..8c6ce47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,40 +1,44 @@ -[project] -name = "fastapi-meeting-service" -version = "0.1.0" -description = "" -authors = [ - {name = "OKEunsu",email = "esok0617@gmail.com"} -] -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "sqlalchemy-utc (>=0.14.0,<0.15.0)", - "aiosqlite (>=0.22.1,<0.23.0)", - "alembic (>=1.17.2,<2.0.0)", - "greenlet (>=3.3.0,<4.0.0)" -] - - -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" - -[dependency-groups] -dev = [ - "pytest (>=9.0.2,<10.0.0)", - "pytest-asyncio (>=1.3.0,<2.0.0)" -] - +[project] +name = "fastapi-meeting-service" +version = "0.1.0" +description = "" +authors = [ + {name = "OKEunsu",email = "esok0617@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "sqlalchemy-utc (>=0.14.0,<0.15.0)", + "aiosqlite (>=0.22.1,<0.23.0)", + "alembic (>=1.17.2,<2.0.0)", + "greenlet (>=3.3.0,<4.0.0)", + "typing-extensions (>=4.0.0,<5.0.0)", + "pwdlib[argon2,bcrypt] (>=0.3.0,<0.4.0)", + "python-jose[crpytography] (>=3.5.0,<4.0.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "pytest (>=9.0.2,<10.0.0)", + "pytest-asyncio (>=1.3.0,<2.0.0)" +] + [tool.pytest.ini_options] minversion = "8.3.4" env = [] addopts = " --strict-markers --tb=short --asyncio-mode=auto -p no:warnings --doctest-modules" python_files = ["tests.py", "test_*.py"] rootdir = "./" -testpaths = ["./tests", "./appserver"] +testpaths = ["./tests"] pythonpath = ["./"] +norecursedirs = ["alembic"] filterwarnings = ["error", "ignore::DeprecationWarning:etcd3.*:"] log_cli = true log_cli_level = "WARNING" -log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" -log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..25befe3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,61 @@ +aiosqlite==0.22.1 +alembic==1.17.2 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.0 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 +bcrypt==5.0.0 +certifi==2025.11.12 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +dnspython==2.8.0 +ecdsa==0.19.1 +email-validator==2.3.0 +exceptiongroup==1.3.1 +fastapi==0.127.0 +greenlet==3.3.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +Jinja2==3.1.6 +Mako==1.3.10 +MarkupSafe==3.0.3 +orjson==3.11.8 +packaging==25.0 +pluggy==1.6.0 +pycparser==2.23 +pydantic==2.12.5 +pydantic-extra-types==2.10.6 +pydantic-settings==2.12.0 +pydantic_core==2.41.5 +pwdlib==0.3.0 +pyasn1==0.6.1 +pytest==9.0.2 +pytest-asyncio==1.3.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python-jose==3.5.0 +python-multipart==0.0.21 +PyYAML==6.0.3 +requests==2.32.5 +rich==14.2.0 +rsa==4.9.1 +sentry-sdk==2.48.0 +six==1.17.0 +SQLAlchemy==2.0.45 +SQLAlchemy-Utc==0.14.0 +sqlmodel==0.0.30 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +ujson==5.11.0 +urllib3==2.6.2 +uvicorn==0.40.0 +watchfiles==1.1.1 +websockets==15.0.1 diff --git a/study_plan.ipynb b/study_plan.ipynb new file mode 100644 index 0000000..29906da --- /dev/null +++ b/study_plan.ipynb @@ -0,0 +1,417 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ๐Ÿ“˜ FastAPI ์™„์ „ ์ •๋ณต ๋…ธํŠธ\n", + "\n", + "์ด ๋…ธํŠธ๋ถ์€ ์—ฌ๋Ÿฌ๋ถ„์ด ์ž‘์„ฑํ•œ ๋ชจ๋“  ์ฝ”๋“œ๋ฅผ ์ง์ ‘ ์‹คํ–‰ํ•ด๋ณด๊ณ  ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋Š” ํ†ตํ•ฉ ์‹ค์Šต ํ™˜๊ฒฝ์ž…๋‹ˆ๋‹ค.\n", + "DB ์ƒ์„ฑ๋ถ€ํ„ฐ API ํ˜ธ์ถœ, ๋ณด์•ˆ ๊ฒ€์ฆ, ๊ทธ๋ฆฌ๊ณ  ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๊นŒ์ง€ ์ˆœ์„œ๋Œ€๋กœ ์ง„ํ–‰ํ•˜๋ฉฐ ์ „์ฒด ํ๋ฆ„์„ ์ตํ˜€๋ด…์‹œ๋‹ค!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. ๐Ÿ›  ํ™˜๊ฒฝ ์„ค์ • (Imports & Setup)\n", + "\n", + "ํ”„๋กœ์ ํŠธ์˜ ๋ชจ๋“  ๋ชจ๋“ˆ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ๋„๋ก ๊ฒฝ๋กœ๋ฅผ ์„ค์ •ํ•˜๊ณ  ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ž„ํฌํŠธํ•ฉ๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import os\n", + "from typing import AsyncGenerator\n", + "\n", + "# ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ ํŒŒ์ด์ฌ ๊ฒฝ๋กœ์— ์ถ”๊ฐ€\n", + "current_dir = os.getcwd()\n", + "if current_dir not in sys.path:\n", + " sys.path.append(current_dir)\n", + "\n", + "import asyncio\n", + "import nest_asyncio\n", + "nest_asyncio.apply()\n", + "\n", + "import pytest\n", + "from httpx import AsyncClient, ASGITransport\n", + "from sqlmodel import SQLModel, select\n", + "from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\n", + "from sqlalchemy.orm import sessionmaker\n", + "\n", + "# FastAPI ์•ฑ ๋ฐ ๋ชจ๋ธ ์ž„ํฌํŠธ\n", + "from fastapi import FastAPI\n", + "from appserver.app import include_routers\n", + "from appserver.db import use_session\n", + "from appserver.apps.account.models import User\n", + "from appserver.apps.account.schemas import UserCreatePayload, LoginPayload\n", + "from appserver.apps.account.utils import hash_password, verify_password\n", + "from dataclasses import dataclass\n", + "\n", + "print(\"โœ… ๋ชจ๋“  ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ž„ํฌํŠธ ์™„๋ฃŒ!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. ๐Ÿ—„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์ถ• (In-Memory DB)\n", + "\n", + "์‹ค์ œ ์„œ๋ฒ„๋ฅผ ๋„์šฐ์ง€ ์•Š๊ณ , ๋…ธํŠธ๋ถ ์•ˆ์—์„œ๋งŒ ์‚ฌ์šฉํ•  \"๊ฐ€์งœ DB(๋ฉ”๋ชจ๋ฆฌ DB)\"๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค.\n", + "์ด ๋‹จ๊ณ„๊ฐ€ ์„ฑ๊ณตํ•ด์•ผ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ณ  ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ๋น„๋™๊ธฐ ์—”์ง„ ์ƒ์„ฑ (๋ฉ”๋ชจ๋ฆฌ DB)\n", + "test_engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n", + "\n", + "async def init_db():\n", + " async with test_engine.begin() as conn:\n", + " # ๋ชจ๋“  ํ…Œ์ด๋ธ” ์ƒ์„ฑ (User ํ…Œ์ด๋ธ” ๋“ฑ)\n", + " await conn.run_sync(SQLModel.metadata.create_all)\n", + " print(\"โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ!\")\n", + "\n", + "# ์ฃผํ”ผํ„ฐ๋Š” ์ด๋ฏธ ๋น„๋™๊ธฐ ๋ฃจํ”„๊ฐ€ ๋Œ๊ณ  ์žˆ์œผ๋ฏ€๋กœ await๋กœ ๋ฐ”๋กœ ์‹คํ–‰\n", + "await init_db()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. ๐Ÿ›ก ๋กœ์ง ๋‹จ์œ„ ํ…Œ์ŠคํŠธ: ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ\n", + "\n", + "`appserver/apps/account/utils.py`์˜ ํ•ต์‹ฌ ๋ณด์•ˆ ๊ธฐ๋Šฅ์„ ๋จผ์ € ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.\n", + "๋‚ด๊ฐ€ ์ž…๋ ฅํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ DB์— ์–ด๋–ป๊ฒŒ ์ €์žฅ๋˜๋Š”์ง€ ๋ˆˆ์œผ๋กœ ํ™•์ธํ•˜์„ธ์š”." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "raw_pw = \"ilovepython\"\n", + "hashed_pw = hash_password(raw_pw)\n", + "\n", + "print(f\"๐Ÿ”‘ ์›๋ณธ ๋น„๋ฐ€๋ฒˆํ˜ธ: {raw_pw}\")\n", + "print(f\"๐Ÿ”’ ํ•ด์‹œ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ: {hashed_pw}\")\n", + "\n", + "# ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ\n", + "assert verify_password(raw_pw, hashed_pw) == True, \"๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ ์‹คํŒจ!\"\n", + "assert verify_password(\"wrongpw\", hashed_pw) == False, \"ํ‹€๋ฆฐ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ†ต๊ณผ๋จ!\"\n", + "print(\"โœ… ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณด์•ˆ ๋กœ์ง ์ •์ƒ ์ž‘๋™!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. ๐ŸŒ ๊ฐ€์ƒ API ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ\n", + "\n", + "๋ธŒ๋ผ์šฐ์ € ์—†์ด ์ฝ”๋“œ๋กœ ์„œ๋ฒ„์™€ ํ†ต์‹ ํ•  ์ˆ˜ ์žˆ๋Š” `AsyncClient`๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค.\n", + "์ด ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์—ฌ๋Ÿฌ๋ถ„ ๋Œ€์‹  `/account/signup`, `/account/login` ๋“ฑ์œผ๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ๊ฒ๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. FastAPI ์•ฑ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ\n", + "app = FastAPI()\n", + "include_routers(app)\n", + "\n", + "# 2. DB ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ (๊ฐ€์งœ DB ์‚ฌ์šฉํ•˜๋„๋ก ์—ฐ๊ฒฐ)\n", + "async def override_use_session() -> AsyncGenerator[AsyncSession, None]:\n", + " async_session = sessionmaker(\n", + " test_engine, class_=AsyncSession, expire_on_commit=False\n", + " )\n", + " async with async_session() as session:\n", + " yield session\n", + "\n", + "app.dependency_overrides[use_session] = override_use_session\n", + "\n", + "# 3. ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ\n", + "client = AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\")\n", + "print(\"โœ… ๊ฐ€์ƒ API ํด๋ผ์ด์–ธํŠธ ์ค€๋น„ ์™„๋ฃŒ!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. ๐Ÿš€ ์‹ค์ „ ์‹œ๋‚˜๋ฆฌ์˜ค 1: ํšŒ์›๊ฐ€์ž… (Sign Up)\n", + "\n", + "์œ ์ €๊ฐ€ ํšŒ์›๊ฐ€์ž… ํผ์„ ์ž‘์„ฑํ•ด์„œ ์ œ์ถœํ•˜๋Š” ์ƒํ™ฉ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•ฉ๋‹ˆ๋‹ค.\n", + "์ง์ ‘ JSON ๋ฐ์ดํ„ฐ๋ฅผ ๋งŒ๋“ค์–ด์„œ POST ์š”์ฒญ์„ ๋ณด๋‚ด๋ด…๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "signup_data = {\n", + " \"username\": \"fastapi_master\",\n", + " \"password\": \"code1234\",\n", + " \"password_again\": \"code1234\",\n", + " \"email\": \"master@example.com\",\n", + " \"display_name\": \"ํŒŒ์ด์ฌ๊ณ ์ˆ˜\"\n", + "}\n", + "\n", + "response = await client.post(\"/account/signup\", json=signup_data)\n", + "\n", + "print(f\"๐Ÿ“ก ์‘๋‹ต ์ƒํƒœ ์ฝ”๋“œ: {response.status_code}\")\n", + "print(f\"๐Ÿ“„ ์‘๋‹ต ๋ฐ์ดํ„ฐ: {response.json()}\")\n", + "\n", + "if response.status_code == 201:\n", + " print(\"โœ… ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต!\")\n", + "else:\n", + " print(\"โŒ ํšŒ์›๊ฐ€์ž… ์‹คํŒจ (๋กœ๊ทธ ํ™•์ธ ํ•„์š”)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. ๐Ÿ” DB ํ™•์ธ: ์ง„์งœ ์ €์žฅ๋˜์—ˆ๋‚˜์š”?\n", + "\n", + "API๋Š” ์„ฑ๊ณตํ–ˆ๋‹ค๊ณ  ํ•˜์ง€๋งŒ, ์ง„์งœ DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด๊ฐ”๋Š”์ง€ ์˜์‹ฌ์Šค๋Ÿฌ์šฐ์‹œ์ฃ ?\n", + "์ง์ ‘ SELECT ์ฟผ๋ฆฌ๋ฅผ ๋‚ ๋ ค์„œ ํ™•์ธํ•ด๋ด…์‹œ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n", + "\n", + "async with async_session() as session:\n", + " # SELECT * FROM user WHERE username = 'fastapi_master'\n", + " stmt = select(User).where(User.username == \"fastapi_master\")\n", + " result = await session.execute(stmt)\n", + " user = result.scalar_one_or_none()\n", + "\n", + " if user:\n", + " print(f\"โœ… DB ์กฐํšŒ ์„ฑ๊ณต!\")\n", + " print(f\"ID: {user.id}\")\n", + " print(f\"Username: {user.username}\")\n", + " print(f\"Email: {user.email}\")\n", + " print(f\"Hashed Password: {user.hashed_password} (์•ˆ์ „ํ•˜๊ฒŒ ์•”ํ˜ธํ™”๋จ)\")\n", + " else:\n", + " print(\"โŒ DB์— ์œ ์ €๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. ๐Ÿš€ ์‹ค์ „ ์‹œ๋‚˜๋ฆฌ์˜ค 2: ๋กœ๊ทธ์ธ (Login)\n", + "\n", + "์ด์ œ ํšŒ์›๊ฐ€์ž…ํ•œ ์•„์ด๋””๋กœ ๋กœ๊ทธ์ธ์„ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.\n", + "์„ฑ๊ณตํ•˜๋ฉด **Access Token(์‹ ๋ถ„์ฆ)**์„ ์ฟ ํ‚ค๋‚˜ ๋ฐ”๋””๋กœ ๋ฐ›์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "login_data = {\n", + " \"username\": \"fastapi_master\",\n", + " \"password\": \"code1234\"\n", + "}\n", + "\n", + "response = await client.post(\"/account/login\", json=login_data)\n", + "\n", + "print(f\"๐Ÿ“ก ์‘๋‹ต ์ƒํƒœ ์ฝ”๋“œ: {response.status_code}\")\n", + "print(f\"๐Ÿ“„ ์‘๋‹ต ๋ฐ”๋””: {response.json()}\")\n", + "\n", + "# ์ฟ ํ‚ค ํ™•์ธ\n", + "cookies = response.cookies\n", + "auth_token = cookies.get(\"auth_token\")\n", + "\n", + "if auth_token:\n", + " print(f\"\\n๐Ÿช ๋ฐœ๊ธ‰๋œ ์ฟ ํ‚ค(auth_token): {auth_token[:20]}... (์ƒ๋žต)\")\n", + " print(\"โœ… ๋กœ๊ทธ์ธ ๋ฐ ํ† ํฐ ๋ฐœ๊ธ‰ ์„ฑ๊ณต!\")\n", + "else:\n", + " print(\"โŒ ์ฟ ํ‚ค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ๋กœ์ง์„ ์ ๊ฒ€ํ•˜์„ธ์š”.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. ๐Ÿš€ ์‹ค์ „ ์‹œ๋‚˜๋ฆฌ์˜ค 3: ๋‚ด ์ •๋ณด ์ˆ˜์ • (Update)\n", + "\n", + "๋กœ๊ทธ์ธ ์ƒํƒœ(`client`๊ฐ€ ์ฟ ๊ธฐ๋ฅผ ๊ธฐ์–ตํ•จ)์—์„œ ๋‹‰๋„ค์ž„์„ ๋ณ€๊ฒฝํ•ด๋ด…๋‹ˆ๋‹ค.\n", + "Dependency Injection(`get_current_user`)์ด ํ† ํฐ์„ ์–ด๋–ป๊ฒŒ ํ•ด์„ํ•ด์„œ ์œ ์ €๋ฅผ ์ฐพ์•„๋‚ด๋Š”์ง€ ์ฒดํ—˜ํ•ฉ๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. ํ˜„์žฌ ์ •๋ณด ํ™•์ธ (@me)\n", + "me_res = await client.get(\"/account/@me\")\n", + "print(f\"๋ณ€๊ฒฝ ์ „ ๋‹‰๋„ค์ž„: {me_res.json()['display_name']}\")\n", + "\n", + "# 2. ์ •๋ณด ์ˆ˜์ • ์š”์ฒญ (PATCH)\n", + "update_data = {\"display_name\": \"AI๋งˆ์Šคํ„ฐ\"}\n", + "patch_res = await client.patch(\"/account/@me\", json=update_data)\n", + "\n", + "# 3. ๋ณ€๊ฒฝ ํ›„ ์ •๋ณด ํ™•์ธ\n", + "me_res_after = await client.get(\"/account/@me\")\n", + "print(f\"๋ณ€๊ฒฝ ํ›„ ๋‹‰๋„ค์ž„: {me_res_after.json()['display_name']}\")\n", + "\n", + "assert me_res_after.json()['display_name'] == \"AI๋งˆ์Šคํ„ฐ\"\n", + "print(\"โœ… ๋‚ด ์ •๋ณด ์ˆ˜์ • ์„ฑ๊ณต!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. ๐Ÿงน ํ…Œ์ŠคํŠธ ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ\n", + "์‚ฌ์šฉํ–ˆ๋˜ ์ž์›์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await client.aclose()\n", + "await test_engine.dispose()\n", + "print(\"โœ… ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ๋ฐ ๋ฆฌ์†Œ์Šค ํ•ด์ œ ์™„๋ฃŒ\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. ๐Ÿ› ๋ฐ์ดํ„ฐ ๋ชจ๋ธ๋ง & ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (Alembic)\n", + "\n", + "์ง€๊ธˆ๊นŒ์ง€๋Š” ์ฝ”๋“œ๊ฐ€ ๊บผ์ง€๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ์‚ฌ๋ผ์ง€๋Š” '๋ฉ”๋ชจ๋ฆฌ DB'๋ฅผ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.\n", + "์ด์ œ๋Š” **ํŒŒ์ผ๋กœ ๋‚จ๋Š” ์ง„์งœ DB(local.db)**๋ฅผ ์‚ฌ์šฉํ•˜๊ณ , ํ…Œ์ด๋ธ” ๊ตฌ์กฐ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” ์‹ค์Šต์„ ํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.\n", + "\n", + "### ์‹ค์Šต ๋ชฉํ‘œ: `Booking` ํ…Œ์ด๋ธ”์— `memo` ํ•„๋“œ ์ถ”๊ฐ€ํ•˜๊ธฐ" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from sqlmodel import Field, Text\n", + "\n", + "model_path = \"appserver/apps/calendar/models.py\"\n", + "\n", + "# 1. Booking ๋ชจ๋ธ ์ฝ”๋“œ์— ์ƒˆ๋กœ์šด ํ•„๋“œ('memo')๋ฅผ ์‚ฝ์ž…ํ•˜๋Š” ํŒŒ์ด์ฌ ์Šคํฌ๋ฆฝํŠธ\n", + "# (๋งˆ์น˜ ์šฐ๋ฆฌ๊ฐ€ ์—๋””ํ„ฐ์—์„œ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•œ ๊ฒƒ์ฒ˜๋Ÿผ ํŒŒ์ผ ๋‚ด์šฉ์„ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค)\n", + "with open(model_path, \"r\", encoding=\"utf-8\") as f:\n", + " content = f.read()\n", + "\n", + "if \"memo: str = Field\" not in content:\n", + " target_string = 'description: str = Field(sa_type=Text, description=\"์˜ˆ์•ฝ ์„ค๋ช…\")'\n", + " new_field = '\\n memo: str = Field(default=\"\", description=\"๋ฉ”๋ชจ\")'\n", + " \n", + " new_content = content.replace(target_string, target_string + new_field)\n", + " \n", + " with open(model_path, \"w\", encoding=\"utf-8\") as f:\n", + " f.write(new_content)\n", + " print(\"โœ… models.py ํŒŒ์ผ์— 'memo' ํ•„๋“œ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค!\")\n", + "else:\n", + " print(\"โ„น๏ธ ์ด๋ฏธ 'memo' ํ•„๋“œ๋กœ ๋ณด์ด๋Š” ์ฝ”๋“œ๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. ๐Ÿ“ฆ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋ช…๋ น์–ด ์‹คํ–‰\n", + "\n", + "์ฝ”๋“œ๊ฐ€ ๋ฐ”๋€Œ์—ˆ์œผ๋‹ˆ, ์ด๋ฅผ DB์— ์•Œ๋ฆฌ๋Š” `Alembic` ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.\n", + "๋…ธํŠธ๋ถ์—์„œ `!`๋ฅผ ์•ž์— ๋ถ™์ด๋ฉด ํ„ฐ๋ฏธ๋„ ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. ๊ธฐ์กด ํ…Œ์ด๋ธ” ์ตœ์‹ ํ™” (ํ˜น์‹œ ์•ˆ ๋œ ๊ฒƒ์ด ์žˆ๋‹ค๋ฉด)\n", + "!alembic upgrade head\n", + "\n", + "# 2. ์ƒˆ๋กœ์šด ๋ณ€๊ฒฝ์‚ฌํ•ญ(๋ฆฌ๋น„์ „) ์ƒ์„ฑ\n", + "# --autogenerate: ์ฝ”๋“œ๋ฅผ ๋ณด๊ณ  ๋ฐ”๋€ ์ ์„ ์ž๋™์œผ๋กœ ์ฐพ์•„๋ผ\n", + "!alembic revision --autogenerate -m \"Add memo field to booking\"\n", + "\n", + "# 3. DB์— ๋ฐ˜์˜ (ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์ถ”๊ฐ€)\n", + "!alembic upgrade head" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. โœ… ์ฒดํฌํฌ์ธํŠธ: DB ํŒŒ์ผ ํ™•์ธ\n", + "\n", + "์ด์ œ ํ”„๋กœ์ ํŠธ ํด๋”์— `local.db` ํŒŒ์ผ์ด ์ƒ๊ฒผ๊ฑฐ๋‚˜ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.\n", + "์ด๋กœ์จ ์—ฌ๋Ÿฌ๋ถ„์€ ๋ฉ”๋ชจ๋ฆฌ DB, ๊ฐ€์ƒ ํด๋ผ์ด์–ธํŠธ, ๊ทธ๋ฆฌ๊ณ  ์‹ค์ œ DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๊นŒ์ง€ **๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ์˜ ์ „ ๊ณผ์ •**์„ ๊ฒฝํ—˜ํ–ˆ์Šต๋‹ˆ๋‹ค.\n", + "\n", + "์ˆ˜๊ณ ํ•˜์…จ์Šต๋‹ˆ๋‹ค! ๐ŸŽ‰" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/tests/apps/account/test_endpoints.py b/tests/apps/account/test_endpoints.py index 651164c..eb71e46 100644 --- a/tests/apps/account/test_endpoints.py +++ b/tests/apps/account/test_endpoints.py @@ -1,109 +1,108 @@ -from typing import TYPE_CHECKING -from fastapi import HTTPException -from appserver.apps.account.endpoints import user_detail -import pytest -from fastapi import HTTPException, status -from fastapi.testclient import TestClient -from appserver.app import app -from appserver.apps.account.models import User -from appserver.apps.calendar.models import Calendar -from sqlmodel import SQLModel -from sqlalchemy.ext.asyncio import AsyncSession -from appserver.db import create_async_engine, create_session - - -async def test_user_detail_successfully(db_session: AsyncSession): - """์ •์ƒ์ ์ธ ์‚ฌ์šฉ์ž ์กฐํšŒ ์‹œ ๋ฐ์ดํ„ฐ๊ฐ€ ์ •ํ™•ํ•œ์ง€ ํ™•์ธ""" - host_user = User( - username="test-hostuser", - password="test", - email="test.hostuser@example.com", - display_name="test", - is_host=True, - ) - db_session.add(host_user) - await db_session.commit() - await db_session.refresh(host_user) # DB์—์„œ ์ƒ์„ฑ๋œ ID ๋“ฑ์„ ๊ฐ€์ ธ์˜ค๊ธฐ - - # user_detail ํ•จ์ˆ˜ ํ˜ธ์ถœ - result = await user_detail(host_user.username, db_session) - - # ๊ฒฐ๊ณผ ๊ฒ€์ฆ - assert result.username == "test-hostuser" - assert result.email == "test.hostuser@example.com" - assert result.display_name == "test" - assert result.is_host is True - - -async def test_user_detail_not_found(db_session: AsyncSession): - """์‚ฌ์šฉ์ž๊ฐ€ ์—†์„ ๋•Œ HTTPException์ด ๋ฐœ์ƒํ•˜๋Š”์ง€ ํ™•์ธ""" - # pytest.raises: ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š”์ง€ ์ฒดํฌํ•˜๋Š” ์ปจํ…์ŠคํŠธ ๋งค๋‹ˆ์ € - with pytest.raises(HTTPException) as exc_info: - await user_detail("not_found", db_session) - # ๋ฐœ์ƒํ•œ ์—๋Ÿฌ์˜ ์ƒํƒœ ์ฝ”๋“œ๊ฐ€ 404์ธ์ง€ ๊ฒ€์ฆ - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND - - -def test_user_detail_by_http_not_found(client: TestClient): - """HTTP ํด๋ผ์ด์–ธํŠธ๋ฅผ ํ†ตํ•ด ์‹ค์ œ ๋ผ์šฐํŒ…๊ณผ ์‘๋‹ต์„ ํ™•์ธ""" - # ์‹ค์ œ API ๊ฒฝ๋กœ๋กœ GET ์š”์ฒญ ์ „์†ก - response = client.get("/account/users/not_found") - # HTTP ์ƒํƒœ ์ฝ”๋“œ ๊ฒ€์ฆ - assert response.status_code == status.HTTP_404_NOT_FOUND - - -async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): - """Integration Test (E2E): ์‹ค์ œ HTTP ์š”์ฒญ/์‘๋‹ต ๊ณผ์ •์„ ํ…Œ์ŠคํŠธ""" - # 1. ๋จผ์ € ํ…Œ์ŠคํŠธ์šฉ ์‚ฌ์šฉ์ž๋ฅผ DB์— ์ƒ์„ฑ - user = User( - username="test-http-user", - password="test", - email="test-http@example.com", - display_name="HTTP Test User", - is_host=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - - # 2. HTTP ํด๋ผ์ด์–ธํŠธ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ - response = client.get(f"/account/users/{user.username}") - - # 3. ์‘๋‹ต ๊ฒ€์ฆ - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["username"] == user.username - assert data["email"] == user.email - assert data["display_name"] == user.display_name - assert data["is_host"] is True - assert data["created_at"] is not None - assert data["updated_at"] is not None - - -async def test_user_detail_for_real_user(db_session: AsyncSession): - """์‹ค์ œ DB ์„ธ์…˜์„ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž ์ƒ์„ฑ ํ›„ ์กฐํšŒ ํ…Œ์ŠคํŠธ""" - # 1. ์‚ฌ์šฉ์ž ์ƒ์„ฑ - user = User( - username="test-real-user", - password="test", - email="test-real@example.com", - display_name="Real Test User", - is_host=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - - # 2. user_detail ํ•จ์ˆ˜๋กœ ์กฐํšŒ - result = await user_detail(user.username, db_session) - - # 3. ๊ฒ€์ฆ - assert result.username == user.username - assert result.email == user.email - assert result.display_name == user.display_name - assert result.is_host is True - - # 4. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์กฐํšŒ ์‹œ 404 ํ™•์ธ - with pytest.raises(HTTPException) as exc_info: - await user_detail("not_found_user", db_session) - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND +from typing import TYPE_CHECKING +from fastapi import HTTPException +from appserver.apps.account.endpoints import user_detail +import pytest +from fastapi import HTTPException, status +from fastapi.testclient import TestClient +from appserver.app import app +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from sqlmodel import SQLModel +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.db import create_async_engine, create_session + +async def test_user_detail_successfully(db_session: AsyncSession): + """์ •์ƒ์ ์ธ ์‚ฌ์šฉ์ž ์กฐํšŒ ์‹œ ๋ฐ์ดํ„ฐ๊ฐ€ ์ •ํ™•ํ•œ์ง€ ํ™•์ธ""" + host_user = User( + username="test-hostuser", + password="test", + email="test.hostuser@example.com", + display_name="test", + is_host=True, + ) + db_session.add(host_user) + await db_session.commit() + await db_session.refresh(host_user) # DB์—์„œ ์ƒ์„ฑ๋œ ID ๋“ฑ์„ ๊ฐ€์ ธ์˜ค๊ธฐ + + # user_detail ํ•จ์ˆ˜ ํ˜ธ์ถœ + result = await user_detail(host_user.username, db_session) + + # ๊ฒฐ๊ณผ ๊ฒ€์ฆ + assert result.username == "test-hostuser" + assert result.email == "test.hostuser@example.com" + assert result.display_name == "test" + assert result.is_host is True + + +async def test_user_detail_not_found(db_session: AsyncSession): + """์‚ฌ์šฉ์ž๊ฐ€ ์—†์„ ๋•Œ HTTPException์ด ๋ฐœ์ƒํ•˜๋Š”์ง€ ํ™•์ธ""" + # pytest.raises: ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š”์ง€ ์ฒดํฌํ•˜๋Š” ์ปจํ…์ŠคํŠธ ๋งค๋‹ˆ์ € + with pytest.raises(HTTPException) as exc_info: + await user_detail("not_found", db_session) + # ๋ฐœ์ƒํ•œ ์—๋Ÿฌ์˜ ์ƒํƒœ ์ฝ”๋“œ๊ฐ€ 404์ธ์ง€ ๊ฒ€์ฆ + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + + +def test_user_detail_by_http_not_found(client: TestClient): + """HTTP ํด๋ผ์ด์–ธํŠธ๋ฅผ ํ†ตํ•ด ์‹ค์ œ ๋ผ์šฐํŒ…๊ณผ ์‘๋‹ต์„ ํ™•์ธ""" + # ์‹ค์ œ API ๊ฒฝ๋กœ๋กœ GET ์š”์ฒญ ์ „์†ก + response = client.get("/account/users/not_found") + # HTTP ์ƒํƒœ ์ฝ”๋“œ ๊ฒ€์ฆ + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): + """Integration Test (E2E): ์‹ค์ œ HTTP ์š”์ฒญ/์‘๋‹ต ๊ณผ์ •์„ ํ…Œ์ŠคํŠธ""" + # 1. ๋จผ์ € ํ…Œ์ŠคํŠธ์šฉ ์‚ฌ์šฉ์ž๋ฅผ DB์— ์ƒ์„ฑ + user = User( + username="test-http-user", + password="test", + email="test-http@example.com", + display_name="HTTP Test User", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + + # 2. HTTP ํด๋ผ์ด์–ธํŠธ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + response = client.get(f"/account/users/{user.username}") + + # 3. ์‘๋‹ต ๊ฒ€์ฆ + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["username"] == user.username + assert data["email"] == user.email + assert data["display_name"] == user.display_name + assert data["is_host"] is True + assert data["created_at"] is not None + assert data["updated_at"] is not None + + +async def test_user_detail_for_real_user(db_session: AsyncSession): + """์‹ค์ œ DB ์„ธ์…˜์„ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž ์ƒ์„ฑ ํ›„ ์กฐํšŒ ํ…Œ์ŠคํŠธ""" + # 1. ์‚ฌ์šฉ์ž ์ƒ์„ฑ + user = User( + username="test-real-user", + password="test", + email="test-real@example.com", + display_name="Real Test User", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + + # 2. user_detail ํ•จ์ˆ˜๋กœ ์กฐํšŒ + result = await user_detail(user.username, db_session) + + # 3. ๊ฒ€์ฆ + assert result.username == user.username + assert result.email == user.email + assert result.display_name == user.display_name + assert result.is_host is True + + # 4. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์กฐํšŒ ์‹œ 404 ํ™•์ธ + with pytest.raises(HTTPException) as exc_info: + await user_detail("not_found_user", db_session) + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/apps/account/test_login_api.py b/tests/apps/account/test_login_api.py new file mode 100644 index 0000000..0a9a1a0 --- /dev/null +++ b/tests/apps/account/test_login_api.py @@ -0,0 +1,23 @@ +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.schemas import LoginPayload +from appserver.apps.account.models import User + +async def test_๋กœ๊ทธ์ธ_์„ฑ๊ณต(host_user: User, client: TestClient): + payload = LoginPayload.model_validate({ + "username": host_user.username, + "password": "testtest", + }) + + response = client.post("/account/login", json=payload.model_dump()) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["access_token"] is not None + assert data["token_type"] == "bearer" + assert data["user"]["username"] == host_user.username + assert data["user"]["display_name"] == host_user.display_name + assert data["user"]["is_host"] == host_user.is_host + cookie = response.cookies.get("auth_token") + assert cookie is not None + assert cookie == data["access_token"] \ No newline at end of file diff --git a/tests/apps/account/test_logout_api.py b/tests/apps/account/test_logout_api.py new file mode 100644 index 0000000..296f302 --- /dev/null +++ b/tests/apps/account/test_logout_api.py @@ -0,0 +1,12 @@ +from fastapi.testclient import TestClient +from fastapi import status +from appserver.apps.account.constants import AUTH_TOKEN_COOKIE_NAME +from appserver.apps.account.models import User + +async def test_๋กœ๊ทธ์•„์›ƒ_์‹œ_์ธ์ฆ_ํ† ํฐ์ด_์‚ญ์ œ๋˜์–ด์•ผ_ํ•œ๋‹ค( + client_with_auth: TestClient, +): + response = client_with_auth.delete("/account/logout") + assert response.status_code == status.HTTP_200_OK + assert response.cookies.get(AUTH_TOKEN_COOKIE_NAME) is None + diff --git a/tests/apps/account/test_me_api.py b/tests/apps/account/test_me_api.py new file mode 100644 index 0000000..a8c0d1a --- /dev/null +++ b/tests/apps/account/test_me_api.py @@ -0,0 +1,41 @@ +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.models import User +from appserver.apps.account.utils import decode_token, create_access_token +from datetime import datetime, timedelta, timezone + +def test_๋‚ด_์ •๋ณด_์กฐํšŒ(client_with_auth: TestClient, host_user: User): + response = client_with_auth.get("/account/@me") + data = response.json() + assert response.status_code == status.HTTP_200_OK + + response_keys = frozenset(data.keys()) + expected_keys = frozenset(["username", "display_name", "is_host", "email", "created_at", "updated_at"]) + assert response_keys == expected_keys + +def test_ํ† ํฐ์ด_์—†๋Š”_๊ฒฝ์šฐ_์˜์‹ฌ์Šค๋Ÿฐ_์ ‘๊ทผ_์˜ค๋ฅ˜๋ฅผ_์ผ์œผํ‚จ๋‹ค(client: TestClient): + response = client.get("/account/@me") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + +def test_์œ ํšจํ•˜์ง€_์•Š์€_ํ† ํฐ์ธ_๊ฒฝ์šฐ_์ธ์ฆ_์˜ค๋ฅ˜๋ฅผ_์ผ์œผํ‚จ๋‹ค(client_with_auth: TestClient): + client_with_auth.cookies["auth_token"] = "invalid_token" + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_๋งŒ๋ฃŒ๋œ_ํ† ํฐ์œผ๋กœ_๋‚ด_์ •๋ณด_์กฐํšŒ(client_with_auth: TestClient): + token = client_with_auth.cookies.get("auth_token", domain="", path="/") + decoded = decode_token(token) + jwt = create_access_token(decoded, timedelta(hours=-1)) + client_with_auth.cookies["auth_token"] = jwt + + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_์œ ์ €๊ฐ€_์กด์žฌํ•˜์ง€_์•Š์€_๊ฒฝ์šฐ_๋‚ด_์ •๋ณด_์กฐํšŒ(client_with_auth: TestClient): + token = client_with_auth.cookies.get("auth_token", domain="", path="/") + decoded = decode_token(token) + decoded["sub"] = "invalid_user_id" + jwt = create_access_token(decoded) + client_with_auth.cookies["auth_token"] = jwt + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/tests/apps/account/test_signup.py b/tests/apps/account/test_signup.py new file mode 100644 index 0000000..2195e74 --- /dev/null +++ b/tests/apps/account/test_signup.py @@ -0,0 +1,86 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.account.endpoints import signup +from appserver.apps.account.models import User +from fastapi.testclient import TestClient +import pytest +from pydantic import ValidationError +from appserver.apps.account.exceptions import DuplicatedUsernameError, DuplicatedEmailError + +async def test_๋ชจ๋“ _์ž…๋ ฅ_ํ•ญ๋ชฉ์„_์œ ํšจํ•œ_๊ฐ’์œผ๋กœ_์ž…๋ ฅํ•˜๋ฉด_๊ณ„์ •์ด_์ƒ์„ฑ๋œ๋‹ค( + client: TestClient, + db_session: AsyncSession + ): + payload = { + "username": "test", + "email": "test@example.com", + "display_name": "test", + "password": "testํ…Œ์ŠคํŠธ1234", + } + + result = await signup(payload, db_session) + + assert isinstance(result, User) + assert result.username == payload["username"] + assert result.email == payload["email"] + assert result.display_name == payload["display_name"] + assert result.is_host is False + +@pytest.mark.parametrize( + "username", + [ + "puddingcamppuddingcamppuddingcamppuddingcamppuddingcamp", + 12345678, + "x", + ] +) + +async def test_์‚ฌ์šฉ์ž๋ช…์ด_์œ ํšจํ•˜์ง€_์•Š์œผ๋ฉด_์‚ฌ์šฉ์ž๋ช…์ด_์œ ํšจํ•˜์ง€_์•Š๋‹ค๋Š”_๋ฉ”์„ธ์ง€๋ฅผ_๋‹ด์€_์˜ค๋ฅ˜๋ฅผ_์ผ์œผํ‚จ๋‹ค( + client: TestClient, + db_session: AsyncSession, + username: str +): + payload = { + "username": username, + "email": "test@example.com", + "display_name": "test", + "password": "testํ…Œ์ŠคํŠธ1234", + } + + with pytest.raises(ValidationError) as exc_info: + await signup(payload, db_session) + +async def test_๊ณ„์ •_ID๊ฐ€_์ค‘๋ณต๋˜๋ฉด_์ค‘๋ณต_๊ณ„์ •_ID_์˜ค๋ฅ˜๋ฅผ_์ผ์œผํ‚จ๋‹ค(db_session: AsyncSession): + payload = { + "username" : "test", + "email" : "test@example.com", + "display_name": "test", + "password": "testํ…Œ์ŠคํŠธ1234", + } + await signup(payload, db_session) + + payload["username"] = "test2" + with pytest.raises(DuplicatedUsernameError) as exc: + await signup(payload, db_session) + +async def test_e_mail_์ฃผ์†Œ๊ฐ€_์ค‘๋ณต๋˜๋ฉด_์ค‘๋ณต_์ด๋ฉ”์ผ_์˜ค๋ฅ˜๋ฅผ_์ผ์œผํ‚จ๋‹ค(db_session: AsyncSession): + payload = { + "username": "test", + "email": "test@example.com", + "display_name": "test", + "password": "testํ…Œ์ŠคํŠธ1234", + } + await signup(payload, db_session) + + payload["username"] = "test2" + with pytest.raises(DuplicatedEmailError) as exc: + await signup(payload, db_session) + +async def test_ํ‘œ์‹œ๋ช…์„_์ž…๋ ฅํ•˜์ง€_์•Š์œผ๋ฉด_๋ฌด์ž‘์œ„_๋ฌธ์ž์—ด_8๊ธ€์ž๋กœ_๋Œ€์‹ ํ•œ๋‹ค(db_session: AsyncSession): + payload = { + "username": "test", + "email": "test@example.com", + "password": "testํ…Œ์ŠคํŠธ1234", + } + user = await signup(payload, db_session) + assert isinstance(user.display_name, str) + assert len(user.display_name) == 8 diff --git a/tests/apps/account/test_signup_api.py b/tests/apps/account/test_signup_api.py new file mode 100644 index 0000000..9518686 --- /dev/null +++ b/tests/apps/account/test_signup_api.py @@ -0,0 +1,36 @@ +from fastapi import status +from fastapi.testclient import TestClient + +async def test_ํšŒ์›๊ฐ€์ž…_์„ฑ๊ณต(client: TestClient): + payload = { + "username": "test", + "email": "test@example.com", + "password": "testํ…Œ์ŠคํŠธ1234", + "password_again": "testํ…Œ์ŠคํŠธ1234", + } + + response = client.post("/account/signup", json=payload) + + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + assert data["username"] == payload["username"] + assert data["email"] == payload["email"] + assert isinstance(data["display_name"], str) + assert len(data["display_name"]) == 8 + +async def test_์‘๋‹ต_๊ฒฐ๊ณผ์—๋Š”_username_display_name_is_host_๋งŒ_์ถœ๋ ฅํ™˜๋‹ค(client: TestClient): + payload = { + "username": "puddingcamp", + "display_name": "ํ‘ธ๋”ฉ์บ ํ”„", + "email": "test@example.com", + "password": "testํ…Œ์ŠคํŠธ1234", + "password_again": "testํ…Œ์ŠคํŠธ1234", + } + + response = client.post("/account/signup", json=payload) + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + + response_keys = frozenset(data.keys()) + expected_keys = frozenset(["username", "display_name", "is_host"]) + assert response_keys == expected_keys \ No newline at end of file diff --git a/tests/apps/account/test_unregister_api.py b/tests/apps/account/test_unregister_api.py new file mode 100644 index 0000000..a7603f8 --- /dev/null +++ b/tests/apps/account/test_unregister_api.py @@ -0,0 +1,21 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.testclient import TestClient +from fastapi import status +from appserver.apps.account.models import User + +async def test_ํšŒ์›ํƒˆํ‡ด_์‹œ_์œ ์ €๊ฐ€_์‚ญ์ œ๋˜์–ด์•ผ_ํ•œ๋‹ค( + client_with_auth: TestClient, + host_user: User, + db_session: AsyncSession, +): + user_id = host_user.id + + assert await db_session.get(User, user_id) is not None + response = client_with_auth.delete("/account/unregister") + + assert response.status_code == status.HTTP_204_NO_CONTENT + # Clear identity map so we don't read cached instance from another session. + db_session.expire_all() + assert await db_session.get(User, user_id) is None + + diff --git a/tests/apps/account/test_update_user_api.py b/tests/apps/account/test_update_user_api.py new file mode 100644 index 0000000..e8fd646 --- /dev/null +++ b/tests/apps/account/test_update_user_api.py @@ -0,0 +1,55 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.models import User +from sqlalchemy.ext.asyncio import AsyncSession + +UPDATABLE_FIELDS = frozenset(["display_name", "email"]) + +@pytest.mark.parametrize("payload", [ + {"display_name": "ํ‘ธ๋”ฉ์บ ํ”„"}, + {"email": "test@example.com"}, + {"display_name": "ํ‘ธ๋”ฉ์บ ํ”„", "email": "test@example.com"}, +]) +async def test_์‚ฌ์šฉ์ž๊ฐ€_๋ณ€๊ฒฝํ•˜๋Š”_ํ•ญ๋ชฉ๋งŒ_๋ณ€๊ฒฝ๋˜๊ณ _๋‚˜๋จธ์ง€๋Š”_๊ธฐ์กด_๊ฐ’์„_์œ ์ง€ํ•œ๋‹ค( + client_with_auth: TestClient, + payload: dict, + host_user: User +): + # ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ณด๊ด€ํ•œ๋‹ค. + before_data = host_user.model_dump() + response = client_with_auth.patch("/account/@me", json=payload) + assert response.status_code == status.HTTP_200_OK + data = response.json() + + # ๋ณ€๊ฒฝ๋œ ํ•ญ๋ชฉ์€ ๋ณ€๊ฒฝ๋œ ๊ฐ’์œผ๋กœ ๋ณ€๊ฒฝ๋˜์–ด์•ผ ํ•œ๋‹ค. + for key, value in payload.items(): + assert data[key] == value + + # ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์€ ํ•ญ๋ชฉ์€ ๊ธฐ์กด ๊ฐ’์„ ์œ ์ง€ํ•œ๋‹ค. + for key in UPDATABLE_FIELDS - frozenset(payload.keys()): + assert data[key] == before_data[key] + +async def test_์ตœ์†Œ_ํ•˜๋‚˜_์ด์ƒ_ํ•ญ๋ชฉ์„_๋ณ€๊ฒฝํ•ด์•ผ_ํ•˜๋ฉฐ_๊ทธ๋ ‡์ง€_์•Š์œผ๋ฉด_์˜ค๋ฅ˜๋ฅผ_์ผ์œผํ‚จ๋‹ค( + client_with_auth: TestClient, +): + response = client_with_auth.patch("/account/@me", json={}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + +async def test_๋น„๋ฐ€๋ฒˆํ˜ธ_๋ณ€๊ฒฝ_์‹œ_ํ•ด์‹ฑ_์ฒ˜๋ฆฌํ•œ_๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€_์ €์žฅ๋˜์–ด์•ผ_ํ•œ๋‹ค( + client_with_auth: TestClient, + host_user: User, + db_session: AsyncSession, +): + before_password = host_user.hashed_password + payload = { + "password": "new_password", + "password_again": "new_password", + } + + response = client_with_auth.patch("/account/@me", json=payload) + assert response.status_code == status.HTTP_200_OK + + await db_session.refresh(host_user) + assert host_user.hashed_password != before_password + diff --git a/tests/apps/calendar/test_calendar.py b/tests/apps/calendar/test_calendar.py new file mode 100644 index 0000000..66a6fed --- /dev/null +++ b/tests/apps/calendar/test_calendar.py @@ -0,0 +1,55 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut +from appserver.apps.calendar.endpoints import host_calendar_detail +from appserver.apps.calendar.exceptions import HostNotFoundError +from appserver.apps.calendar.exceptions import CalendarNotFoundError + +@pytest.mark.parametrize("user_key, expected_type", [ + ("host_user", CalendarDetailOut), + ("guest_user", CalendarOut), + (None, CalendarOut), +]) +async def test_ํ˜ธ์ŠคํŠธ์ธ_์‚ฌ์šฉ์ž์˜_username์œผ๋กœ_์บ˜๋ฆฐ๋”_์ •๋ณด๋ฅผ_๊ฐ€์ ธ์˜จ๋‹ค( + user_key: str | None, + expected_type: type[CalendarOut | CalendarDetailOut], + host_user: User, + host_user_calendar: Calendar, + guest_user: User, + db_session: AsyncSession, +): + users = { + "host_user": host_user, + "guest_user": guest_user, + None: None, + } + user = users[user_key] + + result = await host_calendar_detail(host_user.username, user, db_session) + + assert isinstance(result, expected_type) + result_keys = frozenset(result.model_dump().keys()) + expected_keys = frozenset(expected_type.model_fields.keys()) + assert result_keys == expected_keys + + assert result.topics == host_user_calendar.topics + assert result.description == host_user_calendar.description + if expected_type is CalendarDetailOut: + assert result.google_calendar_id == host_user_calendar.google_calendar_id + + +async def test_์กด์žฌํ•˜์ง€_์•Š๋Š”_์‚ฌ์šฉ์ž์˜_username_์œผ๋กœ_์บ˜๋ฆฐ๋”_์ •๋ณด๋ฅผ_๊ฐ€์ ธ์˜ค๋ ค_ํ•˜๋ฉด_404_์‘๋‹ต์„_๋ฐ˜ํ™˜ํ•œ๋‹ค( + db_session: AsyncSession, +) -> None: + with pytest.raises(HostNotFoundError): + await host_calendar_detail("not_exist_user", None, db_session) + + +async def test_ํ˜ธ์ŠคํŠธ๊ฐ€_์•„๋‹Œ_์‚ฌ์šฉ์ž์˜_username_์œผ๋กœ_์บ˜๋ฆฐ๋”_์ •๋ณด๋ฅผ_๊ฐ€์ ธ์˜ค๋ ค_ํ•˜๋ฉด_404_์‘๋‹ต์„_๋ฐ˜ํ™˜ํ•œ๋‹ค( + guest_user: User, + db_session: AsyncSession, +) -> None: + with pytest.raises(CalendarNotFoundError): + await host_calendar_detail(guest_user.username, None, db_session) \ No newline at end of file diff --git a/tests/apps/calendar/test_calendar_api.py b/tests/apps/calendar/test_calendar_api.py new file mode 100644 index 0000000..9c96a51 --- /dev/null +++ b/tests/apps/calendar/test_calendar_api.py @@ -0,0 +1,53 @@ +from fastapi import status +from fastapi.testclient import TestClient +import pytest +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut +from appserver.apps.calendar.endpoints import host_calendar_detail + +@pytest.mark.parametrize("user_key, expected_type", [ + ("host_user", CalendarDetailOut), + ("guest_user", CalendarOut), + (None, CalendarOut), +]) +async def test_ํ˜ธ์ŠคํŠธ์ธ_์‚ฌ์šฉ์ž์˜_username_์œผ๋กœ_์บ˜๋ฆฐ๋”_์ •๋ณด๋ฅผ_๊ฐ€์ ธ์˜จ๋‹ค( + user_key: str | None, + expected_type: type[CalendarOut | CalendarDetailOut], + host_user: User, + host_user_calendar: Calendar, + client: TestClient, + client_with_auth: TestClient, +) -> CalendarOut | CalendarDetailOut: + clients = { + "host_user": client_with_auth, + "guest_user": client, + None: client, + } + user_client = clients[user_key] + + response = user_client.get(f"/calendar/{host_user.username}") + result = response.json() + assert response.status_code == status.HTTP_200_OK + + expected_obj = expected_tpye.model_validate(result) + + assert expected_obj.topics == host_user_calendar.topics + assert expected_obj.description == host_user_calendar.description + if expected_type is CalendarDetailOut: + assert expected_obj.google_calendar_id == host_user_calendar.google_calendar_id + + +async def test_์กด์žฌํ•˜์ง€_์•Š๋Š”_์‚ฌ์šฉ์ž์˜_username_์œผ๋กœ_์บ˜๋ฆฐ๋”_์ •๋ณด๋ฅผ_๊ฐ€์ ธ์˜ค๋ ค_ํ•˜๋ฉด_404_์‘๋‹ต์„_๋ฐ˜ํ™˜ํ•œ๋‹ค( + client: TestClient, +) -> None: + response = client.get("/calendar/not_exist_user") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_ํ˜ธ์ŠคํŠธ๊ฐ€_์•„๋‹Œ_์‚ฌ์šฉ์ž์˜_username_์œผ๋กœ_์บ˜๋ฆฐ๋”_์ •๋ณด๋ฅผ_๊ฐ€์ ธ์˜ค๋ ค_ํ•˜๋ฉด_404_์‘๋‹ต์„_๋ฐ˜ํ™˜ํ•œ๋‹ค( + guest_user: User, + client: TestClient, +) -> None: + response = client.get(f"/calendar/{guest_user.username}") + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/conftest.py b/tests/conftest.py index e06adea..7d590dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,72 +1,138 @@ -import pytest -import asyncio -from appserver.db import create_async_engine, create_session -# ๋ชจ๋ธ๋“ค์ด metadata์— ๋“ฑ๋ก๋˜๋„๋ก ๋ฐ˜๋“œ์‹œ import -from appserver.apps.account import models -from appserver.apps.calendar import models -from sqlmodel import SQLModel -from fastapi import FastAPI -from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine -from appserver.db import create_engine, create_session, use_session -from appserver.app import include_routers -from fastapi.testclient import TestClient - - -@pytest.fixture(scope="function") -async def db_engine(): - """ํ…Œ์ŠคํŠธ์šฉ ๋น„๋™๊ธฐ ์—”์ง„์„ ์ƒ์„ฑํ•˜๋Š” fixture""" - dsn = "sqlite+aiosqlite:///:memory:" - engine = create_async_engine(dsn) - - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - - yield engine - - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.drop_all) - - await engine.dispose() - - -@pytest.fixture(scope="function") -async def db_session(db_engine: AsyncEngine): - """ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋…๋ฆฝ๋œ ํŠธ๋žœ์žญ์…˜์„ ์ œ๊ณตํ•˜๋Š” ์„ธ์…˜ fixture""" - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - await session.rollback() - - +import pytest +import asyncio +from appserver.db import create_async_engine, create_session +# ๋ชจ๋ธ๋“ค์ด metadata์— ๋“ฑ๋ก๋˜๋„๋ก ๋ฐ˜๋“œ์‹œ import +from appserver.apps.account import models as account_models +from appserver.apps.calendar import models as calendar_models # ensure Calendar model is registered +from sqlmodel import SQLModel +from fastapi import FastAPI, status +from appserver.apps.account.schemas import LoginPayload +from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine +from appserver.db import create_engine, create_session, use_session +from appserver.app import include_routers +from fastapi.testclient import TestClient +from appserver.apps.account.utils import hash_password + +@pytest.fixture(scope="function") +async def db_engine(): + """ํ…Œ์ŠคํŠธ์šฉ ๋น„๋™๊ธฐ ์—”์ง„์„ ์ƒ์„ฑํ•˜๋Š” fixture""" + dsn = "sqlite+aiosqlite:///:memory:" + engine = create_async_engine(dsn) + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + yield engine + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + + await engine.dispose() + + +@pytest.fixture(scope="function") +async def db_session(db_engine: AsyncEngine): + """ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋…๋ฆฝ๋œ ํŠธ๋žœ์žญ์…˜์„ ์ œ๊ณตํ•˜๋Š” ์„ธ์…˜ fixture""" + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest.fixture() +def fastapi_app(db_engine: AsyncEngine): + """ + ํ…Œ์ŠคํŠธ์šฉ FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•˜๋Š” fixture์ž…๋‹ˆ๋‹ค. + ์‹ค์ œ DB ์„ธ์…˜ ์ƒ์„ฑ ๋กœ์ง์„ ํ…Œ์ŠคํŠธ์šฉ ์„ธ์…˜์œผ๋กœ ๊ต์ฒดํ•ฉ๋‹ˆ๋‹ค. + """ + # 1. ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๋…๋ฆฝ์ ์ธ FastAPI ๊ฐ์ฒด ์ƒ์„ฑ + app = FastAPI() + + # 2. ์ •์˜๋œ ๋ชจ๋“  API ๊ฒฝ๋กœ(Router)๋ฅผ ์•ฑ์— ๋“ฑ๋ก + include_routers(app) + + # 3. ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ ํ•จ์ˆ˜ + # TestClient๋Š” ๋™๊ธฐ์ด๋ฏ€๋กœ, ๋น„๋™๊ธฐ ์„ธ์…˜์„ ๋™๊ธฐ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์•ผ ํ•จ + async def override_use_session(): + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + + app.dependency_overrides[use_session] = override_use_session + return app + + +@pytest.fixture() +def client(fastapi_app: FastAPI): + """ + ํ…Œ์ŠคํŠธ์šฉ HTTP ํด๋ผ์ด์–ธํŠธ๋ฅผ ์ƒ์„ฑํ•˜๋Š” fixture์ž…๋‹ˆ๋‹ค. + ์ƒ์„ฑ๋œ fastapi_app(์˜์กด์„ฑ์ด ๊ต์ฒด๋œ ์•ฑ)์„ ์‹คํ–‰ํ•˜์—ฌ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ค€๋น„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. + """ + # TestClient๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ๋น„๋™๊ธฐ ํ•จ์ˆ˜๋ฅผ ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰ํ•ด์ค๋‹ˆ๋‹ค + with TestClient(fastapi_app) as client: + yield client + +@pytest.fixture() +async def host_user(db_session: AsyncSession): + """ + ํ…Œ์ŠคํŠธ์šฉ ํ˜ธ์ŠคํŠธ ์‚ฌ์šฉ์ž fixture + """ + user = account_models.User( + username="puddingcamp", + hashed_password=hash_password("testtest"), + password=hash_password("testtest"), + email="puddingcamp@example.com", + display_name="ํ‘ธ๋”ฉ์บ ํ”„", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.flush(user) + return user + +@pytest.fixture() +def client_with_auth(fastapi_app: FastAPI, host_user: account_models.User): + """ + host_user๋กœ ๋กœ๊ทธ์ธํ•œ ์ƒํƒœ์˜ TestClient๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (auth_token ์ฟ ํ‚ค ํฌํ•จ) + """ + payload = LoginPayload( + username=host_user.username, + password="testtest", + ) + + with TestClient(fastapi_app) as client: + response = client.post("/account/login", json=payload.model_dump()) + assert response.status_code == status.HTTP_200_OK + + auth_token = response.cookies.get("auth_token") + assert auth_token is not None + + client.cookies["auth_token"] = auth_token + yield client + +@pytest.fixture() +async def guest_user(db_session: AsyncSession): + user = account_models.User( + username = "puddingcafe", + hashed_password=hash_password("testtest"), + email="puddingcafe@example.com", + display_name="ํ‘ธ๋”ฉ์นดํŽ˜", + is_host=False, + ) + db_session.add(user) + await db_session.commit() + await db_session.flush() + return user + @pytest.fixture() -def fastapi_app(db_engine: AsyncEngine): - """ - ํ…Œ์ŠคํŠธ์šฉ FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•˜๋Š” fixture์ž…๋‹ˆ๋‹ค. - ์‹ค์ œ DB ์„ธ์…˜ ์ƒ์„ฑ ๋กœ์ง์„ ํ…Œ์ŠคํŠธ์šฉ ์„ธ์…˜์œผ๋กœ ๊ต์ฒดํ•ฉ๋‹ˆ๋‹ค. - """ - # 1. ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๋…๋ฆฝ์ ์ธ FastAPI ๊ฐ์ฒด ์ƒ์„ฑ - app = FastAPI() - - # 2. ์ •์˜๋œ ๋ชจ๋“  API ๊ฒฝ๋กœ(Router)๋ฅผ ์•ฑ์— ๋“ฑ๋ก - include_routers(app) - - # 3. ์˜์กด์„ฑ ์˜ค๋ฒ„๋ผ์ด๋“œ ํ•จ์ˆ˜ - # TestClient๋Š” ๋™๊ธฐ์ด๋ฏ€๋กœ, ๋น„๋™๊ธฐ ์„ธ์…˜์„ ๋™๊ธฐ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์•ผ ํ•จ - async def override_use_session(): - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - - app.dependency_overrides[use_session] = override_use_session - return app - - -@pytest.fixture() -def client(fastapi_app: FastAPI): - """ - ํ…Œ์ŠคํŠธ์šฉ HTTP ํด๋ผ์ด์–ธํŠธ๋ฅผ ์ƒ์„ฑํ•˜๋Š” fixture์ž…๋‹ˆ๋‹ค. - ์ƒ์„ฑ๋œ fastapi_app(์˜์กด์„ฑ์ด ๊ต์ฒด๋œ ์•ฑ)์„ ์‹คํ–‰ํ•˜์—ฌ ์š”์ฒญ์„ ๋ณด๋‚ผ ์ค€๋น„๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. - """ - # TestClient๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ๋น„๋™๊ธฐ ํ•จ์ˆ˜๋ฅผ ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰ํ•ด์ค๋‹ˆ๋‹ค - with TestClient(fastapi_app) as client: - yield client +async def host_user_calendar(db_session: AsyncSession, host_user: account_models.User): + calendar = calendar_models.Calendar( + host_id=host_user.id, + topics=["ํ‘ธ๋”ฉ์บ ํ”„", "ํ‘ธ๋”ฉ์บ ํ”„2"], + google_calendar_id="1234567890", + ) + db_session.add(calendar) + await db_session.commit() + await db_session.refresh(host_user) + await db_session.flush() + return calendar diff --git a/tests/libs/datetime/test_calendar.py b/tests/libs/datetime/test_calendar.py index 7483653..74ed7ef 100644 --- a/tests/libs/datetime/test_calendar.py +++ b/tests/libs/datetime/test_calendar.py @@ -1,57 +1,57 @@ -from appserver.libs.datetime.calendar import get_start_weekday_of_month, get_last_day_of_month, get_range_days_of_month -import pytest - -def test_get_start_weekday_of_month(): - assert get_start_weekday_of_month(2024, 12) == 6 - assert get_start_weekday_of_month(2025, 2) == 5 - -def test_get_last_day_of_month(): - assert get_last_day_of_month(2024, 2) == 29 - assert get_last_day_of_month(2025, 2) == 28 - assert get_last_day_of_month(2024, 4) == 30 - assert get_last_day_of_month(2024, 12) == 31 - -# @pytest.mark.parametrize: ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ ์žฅ์‹์ž -# ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ํ•˜๋‚˜๋ฅผ ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐ๋ฅผ ํ•ด์„œ ์—ฌ๋Ÿฌ ๊ฐœ ๋งŒ๋“ค ํ•„์š” ์—†์ด, -# ๋ฐ์ดํ„ฐ๋งŒ ๋ฆฌ์ŠคํŠธ๋กœ ๊ด€๋ฆฌํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ฝ”๋“œ๊ฐ€ ๋งค์šฐ ๊น”๋” -@pytest.mark.parametrize("year, month, expected", [ - (2024, 12, 6), # ์ผ€์ด์Šค 1: 2024๋…„ 12์›”์€ ์ผ์š”์ผ(6) ์‹œ์ž‘ - (2025, 2, 5), # ์ผ€์ด์Šค 2: 2025๋…„ 2์›”์€ ํ† ์š”์ผ(5) ์‹œ์ž‘ -]) -def test_get_start_weekday_of_month(year, month, expected): - """ - ๋‹ค์–‘ํ•œ ์—ฐ๋„์™€ ์›”์— ๋Œ€ํ•ด ์‹œ์ž‘ ์š”์ผ ๊ณ„์‚ฐ ๋กœ์ง์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. - - Args: - year (int): ํ…Œ์ŠคํŠธํ•  ์—ฐ๋„ - month (int): ํ…Œ์ŠคํŠธํ•  ์›” - expected (int): ๊ธฐ๋Œ€๋˜๋Š” ์‹œ์ž‘ ์š”์ผ ๊ฒฐ๊ณผ๊ฐ’ (0~6) - """ - # [์ฃผ์„ ๊ณต๋ถ€ ํฌ์ธํŠธ] - # parametrize์— ์ ํžŒ ๋ฐ์ดํ„ฐ๋“ค์ด ์ˆœ์„œ๋Œ€๋กœ year, month, expected ๋ณ€์ˆ˜์— ๋“ค์–ด์˜ต๋‹ˆ๋‹ค. - # ์ฆ‰, ์ด ํ•จ์ˆ˜๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ์ด 2๋ฒˆ(๋ฆฌ์ŠคํŠธ์˜ ๊ฐœ์ˆ˜๋งŒํผ) ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. - assert get_start_weekday_of_month(year, month) == expected - -@pytest.mark.parametrize("year, month, expected", [ - (2024, 2, 29), - (2025, 2, 28), - (2024, 4, 30), - (2024, 12, 31), -]) -def test_get_last_day_of_month(year, month, expected): - assert get_last_day_of_month(year, month) == expected - -@pytest.mark.parametrize("year, month, expected_padding_count, expected_total_count", [ - (2024, 3, 5, 36), - (2024, 2, 4, 33), - (2025, 2, 6, 34), - (2024, 4, 1, 31), - (2024, 12, 0, 31), -]) -def test_get_range_days_of_month(year, month, expected_padding_count, expected_total_count): - days = get_range_days_of_month(year, month) - padding_count = days[:expected_padding_count] - - assert sum(padding_count) == 0 - assert days[expected_padding_count] == 1 +from appserver.libs.datetime.calendar import get_start_weekday_of_month, get_last_day_of_month, get_range_days_of_month +import pytest + +def test_get_start_weekday_of_month(): + assert get_start_weekday_of_month(2024, 12) == 6 + assert get_start_weekday_of_month(2025, 2) == 5 + +def test_get_last_day_of_month(): + assert get_last_day_of_month(2024, 2) == 29 + assert get_last_day_of_month(2025, 2) == 28 + assert get_last_day_of_month(2024, 4) == 30 + assert get_last_day_of_month(2024, 12) == 31 + +# @pytest.mark.parametrize: ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ ์žฅ์‹์ž +# ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜ ํ•˜๋‚˜๋ฅผ ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐ๋ฅผ ํ•ด์„œ ์—ฌ๋Ÿฌ ๊ฐœ ๋งŒ๋“ค ํ•„์š” ์—†์ด, +# ๋ฐ์ดํ„ฐ๋งŒ ๋ฆฌ์ŠคํŠธ๋กœ ๊ด€๋ฆฌํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ฝ”๋“œ๊ฐ€ ๋งค์šฐ ๊น”๋” +@pytest.mark.parametrize("year, month, expected", [ + (2024, 12, 6), # ์ผ€์ด์Šค 1: 2024๋…„ 12์›”์€ ์ผ์š”์ผ(6) ์‹œ์ž‘ + (2025, 2, 5), # ์ผ€์ด์Šค 2: 2025๋…„ 2์›”์€ ํ† ์š”์ผ(5) ์‹œ์ž‘ +]) +def test_get_start_weekday_of_month(year, month, expected): + """ + ๋‹ค์–‘ํ•œ ์—ฐ๋„์™€ ์›”์— ๋Œ€ํ•ด ์‹œ์ž‘ ์š”์ผ ๊ณ„์‚ฐ ๋กœ์ง์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. + + Args: + year (int): ํ…Œ์ŠคํŠธํ•  ์—ฐ๋„ + month (int): ํ…Œ์ŠคํŠธํ•  ์›” + expected (int): ๊ธฐ๋Œ€๋˜๋Š” ์‹œ์ž‘ ์š”์ผ ๊ฒฐ๊ณผ๊ฐ’ (0~6) + """ + # [์ฃผ์„ ๊ณต๋ถ€ ํฌ์ธํŠธ] + # parametrize์— ์ ํžŒ ๋ฐ์ดํ„ฐ๋“ค์ด ์ˆœ์„œ๋Œ€๋กœ year, month, expected ๋ณ€์ˆ˜์— ๋“ค์–ด์˜ต๋‹ˆ๋‹ค. + # ์ฆ‰, ์ด ํ•จ์ˆ˜๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ์ด 2๋ฒˆ(๋ฆฌ์ŠคํŠธ์˜ ๊ฐœ์ˆ˜๋งŒํผ) ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. + assert get_start_weekday_of_month(year, month) == expected + +@pytest.mark.parametrize("year, month, expected", [ + (2024, 2, 29), + (2025, 2, 28), + (2024, 4, 30), + (2024, 12, 31), +]) +def test_get_last_day_of_month(year, month, expected): + assert get_last_day_of_month(year, month) == expected + +@pytest.mark.parametrize("year, month, expected_padding_count, expected_total_count", [ + (2024, 3, 5, 36), + (2024, 2, 4, 33), + (2025, 2, 6, 34), + (2024, 4, 1, 31), + (2024, 12, 0, 31), +]) +def test_get_range_days_of_month(year, month, expected_padding_count, expected_total_count): + days = get_range_days_of_month(year, month) + padding_count = days[:expected_padding_count] + + assert sum(padding_count) == 0 + assert days[expected_padding_count] == 1 assert len(days) == expected_total_count \ No newline at end of file diff --git a/tests/test_hello.py b/tests/test_hello.py index 7b077ca..b31c707 100644 --- a/tests/test_hello.py +++ b/tests/test_hello.py @@ -1,13 +1,13 @@ -# ํ…Œ์ŠคํŠธ-hello -def test_hello(): - assert True - -def test_hello(): - assert False - -# ๋”ํ•˜๊ธฐ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธํ•˜๊ธฐ -def add(a, b): - return a + b - -def test_add(): +# ํ…Œ์ŠคํŠธ-hello +def test_hello(): + assert True + +def test_hello(): + assert False + +# ๋”ํ•˜๊ธฐ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธํ•˜๊ธฐ +def add(a, b): + return a + b + +def test_add(): assert add(1, 2) == 3 \ No newline at end of file