From c8cb13429f4e2e2921f080056c91a60f7c4ac764 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Tue, 19 Nov 2024 17:05:21 +0000 Subject: [PATCH 01/73] Minor change to Architecture header and alt text --- docs/architecture.qmd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.qmd b/docs/architecture.qmd index 3849176..10a26eb 100644 --- a/docs/architecture.qmd +++ b/docs/architecture.qmd @@ -2,7 +2,7 @@ title: "Architecture" --- -## Architecture +## Data flow This application uses a Post-Redirect-Get (PRG) pattern. The user submits a form, which sends a POST request to a FastAPI endpoint on the server. The database is updated, and the user is redirected to a GET endpoint, which fetches the updated data and re-renders the Jinja2 page template with the new data. @@ -39,6 +39,6 @@ dot.edge('F', 'G') dot.render('static/webapp_flow', format='png', cleanup=True) ``` -![Webapp Flow](static/webapp_flow.png) +![Data flow diagram](static/webapp_flow.png) The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. \ No newline at end of file From e974d00b8641d7104fff4a1665ad086506d302a5 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Tue, 19 Nov 2024 17:07:24 +0000 Subject: [PATCH 02/73] Make features display as bullet points --- docs/index.qmd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/index.qmd b/docs/index.qmd index 2974b31..a9355c6 100644 --- a/docs/index.qmd +++ b/docs/index.qmd @@ -25,12 +25,14 @@ The design philosophy of the template is to prefer low-level, best-in-class open ## Tech Stack **Core frameworks:** + - [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework - [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine - [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine - [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM **Additional technologies:** + - [Poetry](https://python-poetry.org/): Python dependency manager - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization From da6f2b8877c93e4dc3dbcf96c464e23d580c29e6 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Tue, 19 Nov 2024 17:09:09 +0000 Subject: [PATCH 03/73] Minor changes to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c204737..8ebc500 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,14 @@ The design philosophy of the template is to prefer low-level, best-in-class open ## Tech stack **Core frameworks:** + - [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework - [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine - [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine - [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM **Additional technologies:** + - [Poetry](https://python-poetry.org/): Python dependency manager - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization From ed2ab8154245f29e4ed0493806a87113ab2cfa31 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Tue, 19 Nov 2024 17:26:47 +0000 Subject: [PATCH 04/73] Minor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ebc500..6bc2646 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This README provides a high-level overview. See the **[full documentation websit ## Features -*FastAPI, Jinja2, PostgreSQL Webapp Template* combines three of the most lightweight and performant open-source web development frameworks in existence into a customizable webapp template with: +*FastAPI, Jinja2, PostgreSQL Webapp Template* combines three of the most lightweight and performant open-source web development frameworks into a customizable webapp template with: - Pure Python backend - Low-Javascript frontend From dde68d9500fe36a5b331653fc2fe66dd04dfb4b6 Mon Sep 17 00:00:00 2001 From: AkanshuS Date: Wed, 20 Nov 2024 18:01:47 -0500 Subject: [PATCH 05/73] Update user.py Deletes the current_user using session.delete() --- routers/user.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routers/user.py b/routers/user.py index df7d698..367b7da 100644 --- a/routers/user.py +++ b/routers/user.py @@ -77,4 +77,6 @@ async def delete_account( # Mark the user as deleted current_user.deleted = True session.commit() + #Deletes user + session.delete(current_user) return RedirectResponse(url="/", status_code=303) From 60468fea548bf65dfab232ce2a77fbd70c913612 Mon Sep 17 00:00:00 2001 From: AkanshuS Date: Wed, 20 Nov 2024 18:05:03 -0500 Subject: [PATCH 06/73] Update user.py --- routers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/user.py b/routers/user.py index 367b7da..1904ec1 100644 --- a/routers/user.py +++ b/routers/user.py @@ -77,6 +77,6 @@ async def delete_account( # Mark the user as deleted current_user.deleted = True session.commit() - #Deletes user + # Deletes user session.delete(current_user) return RedirectResponse(url="/", status_code=303) From 8a628835991edb9606acff7971636a82ec3581d6 Mon Sep 17 00:00:00 2001 From: AkanshuS Date: Fri, 22 Nov 2024 16:38:31 -0500 Subject: [PATCH 07/73] Logs Out --- routers/user.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routers/user.py b/routers/user.py index 1904ec1..86d3710 100644 --- a/routers/user.py +++ b/routers/user.py @@ -77,6 +77,8 @@ async def delete_account( # Mark the user as deleted current_user.deleted = True session.commit() + #Logs Out + router.get("/logout", response_class=RedirectResponse) # Deletes user session.delete(current_user) return RedirectResponse(url="/", status_code=303) From acec7d0e0b33358f99e2ed6c94f8b1cd28130af6 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sat, 23 Nov 2024 16:52:57 +0000 Subject: [PATCH 08/73] Added notes on error handling patterns to architecture section of docs --- docs/architecture.qmd | 46 +++++++++++++++++++++++++++++++++++++++++- docs/customization.qmd | 1 - 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/architecture.qmd b/docs/architecture.qmd index cf44957..768c839 100644 --- a/docs/architecture.qmd +++ b/docs/architecture.qmd @@ -59,4 +59,48 @@ dot.render('static/data_flow', format='png', cleanup=True) ![Data flow diagram](static/data_flow.png) -The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. \ No newline at end of file +The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. + +## Form validation flow + +We've experimented with several approaches to validating form inputs in the FastAPI endpoints. + +### Objectives + +Ideally, on an invalid input, we would redirect the user back to the form, preserving their inputs and displaying an error message about which input was invalid. + +This would keep the error handling consistent with the PRG pattern described in the [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture) section of this documentation. + +To keep the code DRY, we'd also like to handle such validation with Pydantic dependencies, Python exceptions, and exception-handling middleware as much as possible. + +### Obstacles + +One challenge is that if we redirect back to the page with the form, the page is re-rendered with empty form fields. + +This can be overcome by passing the inputs from the request as context variables to the template. + +But that's a bit clunky, because then we have to support form-specific context variables in every form page and corresponding GET endpoint. + +Also, we have to: + +1. access the request object (which is not by default available to our middleware), and +2. extract the form inputs (at least one of which is invalid in this error case), and +3. pass the form inputs to the template (which is a bit challenging to do in a DRY way since there are different sets of form inputs for different forms). + +Solving these challenges is possible, but gets high-complexity pretty quickly. + +### Approaches + +The best solution, I think, is to use really robust client-side form validation to prevent invalid inputs from being sent to the server in the first place. That makes it less important what we do on the server side, although we still need to handle the server-side error case as a backup in the event that something slips past our validation on the client side. + +Here are some patterns we've considered for server-side error handling: + +| ID | Approach | Returns to same page | Preserves form inputs | Follows PRG pattern | Complexity | +|-----|----------|-------------------|-------------------|------------------|------------| +| 1 | Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button | No | Yes | Yes | Low | +| 2 | Validate in FastAPI endpoint function body, redirect to origin page with error message query param | Yes | No | Yes | Medium | +| 3 | Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context | Yes | Yes | Yes | High | +| 4 | Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputs | Yes | Yes | Yes | High | +| 5 | Validate in either Pydantic dependency or function endpoint body and directly return error message in JSON, then mount it with HTMX or some simple layout-level Javascript | Yes | Yes | No | Low | + +Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. \ No newline at end of file diff --git a/docs/customization.qmd b/docs/customization.qmd index 2e42344..a3dfe2e 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -2,4 +2,3 @@ title: "Customization" --- -## Under construction \ No newline at end of file From 78dd03bfc51521a7b0d78bbb961d0469a70b6d58 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sat, 23 Nov 2024 17:20:29 +0000 Subject: [PATCH 09/73] Styled table in documentation for better borders and column widths --- docs/architecture.qmd | 76 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/architecture.qmd b/docs/architecture.qmd index 768c839..7dfd136 100644 --- a/docs/architecture.qmd +++ b/docs/architecture.qmd @@ -95,12 +95,74 @@ The best solution, I think, is to use really robust client-side form validation Here are some patterns we've considered for server-side error handling: -| ID | Approach | Returns to same page | Preserves form inputs | Follows PRG pattern | Complexity | -|-----|----------|-------------------|-------------------|------------------|------------| -| 1 | Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button | No | Yes | Yes | Low | -| 2 | Validate in FastAPI endpoint function body, redirect to origin page with error message query param | Yes | No | Yes | Medium | -| 3 | Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context | Yes | Yes | Yes | High | -| 4 | Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputs | Yes | Yes | Yes | High | -| 5 | Validate in either Pydantic dependency or function endpoint body and directly return error message in JSON, then mount it with HTMX or some simple layout-level Javascript | Yes | Yes | No | Low | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDApproachReturns to same pagePreserves form inputsFollows PRG patternComplexity
1Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" buttonNoYesYesLow
2Validate in FastAPI endpoint function body, redirect to origin page with error message query paramYesNoYesMedium
3Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
4Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
5Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level JavascriptYesYesNoLow
Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. \ No newline at end of file From 18d57ae7d29a353d1a82a535bbca88b20eacdc40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:24:29 +0000 Subject: [PATCH 10/73] Bump tornado from 6.4.1 to 6.4.2 Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.4.1 to 6.4.2. - [Changelog](https://github.com/tornadoweb/tornado/blob/v6.4.2/docs/releases.rst) - [Commits](https://github.com/tornadoweb/tornado/compare/v6.4.1...v6.4.2) --- updated-dependencies: - dependency-name: tornado dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index 950430d..204263b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2700,22 +2700,22 @@ test = ["pytest", "ruff"] [[package]] name = "tornado" -version = "6.4.1" +version = "6.4.2" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.8" files = [ - {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, - {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, - {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, - {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, - {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, - {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, - {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, - {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, - {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, - {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, - {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, + {file = "tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1"}, + {file = "tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803"}, + {file = "tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec"}, + {file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946"}, + {file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf"}, + {file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634"}, + {file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73"}, + {file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c"}, + {file = "tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482"}, + {file = "tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38"}, + {file = "tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b"}, ] [[package]] From f3ce82d4a20b8e733435967963fa8c6a2dbee95d Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 00:08:40 +0000 Subject: [PATCH 11/73] Added customization.qmd content on project design patterns --- docs/customization.qmd | 252 +++++++++++++++++++++++++++++++++++ migrations/set_up_db.py | 30 ----- tests/conftest.py | 40 +++++- tests/test_authentication.py | 34 ----- 4 files changed, 290 insertions(+), 66 deletions(-) delete mode 100644 migrations/set_up_db.py diff --git a/docs/customization.qmd b/docs/customization.qmd index a3dfe2e..f5b0f29 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -2,3 +2,255 @@ title: "Customization" --- +## Development workflow + +### Dependency management with Poetry + +The project uses Poetry to manage dependencies: + +- Add new dependency: `poetry add ` +- Add development dependency: `poetry add --dev ` +- Remove dependency: `poetry remove ` +- Update lock file: `poetry lock` +- Install dependencies: `poetry install` +- Update all dependencies: `poetry update` + +### Testing + +The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! + +The following fixtures, defined in `tests/conftest.py`, are available in the test suite: + +- `engine`: Creates a new SQLModel engine for the test database. +- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. +- `session`: Provides a session for database operations in tests. +- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. +- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session. +- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. + +To run the tests, use these commands: + +- Run all tests: `pytest` +- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` +- Run particular test files by name: `pytest ` +- Run particular tests by name: `pytest -k ` + +### Type checking with mypy + +The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: + +```bash +mypy +``` + +We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change! + +## Project structure + +### Customizable folders and files + +- FastAPI application entry point and GET routes: `main.py` +- FastAPI POST routes: `routers/` + - User authentication endpoints: `auth.py` + - User profile management endpoints: `user.py` + - Organization management endpoints: `organization.py` + - Role management endpoints: `role.py` +- Jinja2 templates: `templates/` +- Static assets: `static/` +- Unit tests: `tests/` +- Test database configuration: `docker-compose.yml` +- Helper functions: `utils/` + - Auth helpers: `auth.py` + - Database helpers: `db.py` + - Database models: `models.py` +- Environment variables: `.env` +- CI/CD configuration: `.github/` +- Project configuration: `pyproject.toml` +- Quarto documentation: + - Source: `index.qmd` + `docs/` + - Configuration: `_quarto.yml` + +Most everything else is auto-generated and should not be manually modified. + +### Defining a web backend with FastAPI + +We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. + +We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) + +#### Routing patterns in this template + +In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. + +We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: + +```python +# -- Authenticated Routes -- +``` + +Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. + +### HTML templating with Jinja2 + +To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. + +With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. + +#### Context variables + +Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: + +```python +@app.get("/welcome") +async def welcome(request: Request): + return templates.TemplateResponse( + "welcome.html", + {"username": "Alice"} + ) +``` + +In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. + +### Writing type annotated code + +Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. + +If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. + +For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. + +#### Defining request models and custom validators + +For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: + +```python +class PasswordMismatchError(HTTPException): + def __init__(self, field: str = "confirm_password"): + super().__init__( + status_code=422, + detail={ + "field": field, + "message": "The passwords you entered do not match" + } + ) + +class UserRegister(BaseModel): + name: str + email: EmailStr + password: str + confirm_password: str + + # Custom validators are added as class attributes + @field_validator("confirm_password", check_fields=False) + def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: + if v != values["password"]: + raise PasswordMismatchError() + return v + # ... +``` + +We then add this request model as a dependency in the signature of our POST route: + +```python +@app.post("/register") +async def register(request: UserRegister = Depends()): + # ... +``` + +When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. + +(Note that these examples are simplified versions of the actual code.) + +#### Converting form data to request models + +In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: + +```python +class UserRegister(BaseModel): + # ... + + @classmethod + async def as_form( + cls, + name: str = Form(...), + email: EmailStr = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) + ): + return cls( + name=name, + email=email, + password=password, + confirm_password=confirm_password + ) +``` + +#### Middleware exception handling + +Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. + +This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses. + +Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific. + +Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: + +```python +@app.exception_handler(PasswordMismatchError) +async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): + return templates.TemplateResponse( + request, + "errors/validation_error.html", + { + "status_code": 422, + "errors": {"error": exc.detail} + }, + status_code=422, + ) +``` + +### Database configuration and access with SQLModel + +SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. + +#### Models and relationships + +Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: + +- `Organization`: Represents a company or team +- `User`: Represents a user account +- `Role`: Represents a discrete set of user permissions within an organization +- `Permission`: Represents specific actions a user can perform +- `RolePermissionLink`: Maps roles to their allowed permissions +- `PasswordResetToken`: Manages password reset functionality + +Models can have relationships with other models using SQLModel's `Relationship` field. For example: + +```python +class User(SQLModel, table=True): + # ... other fields ... + organization: Optional["Organization"] = Relationship(back_populates="users") + role: Optional["Role"] = Relationship(back_populates="users") +``` + +This creates a many-to-one relationship between users and organizations, and between users and roles. + +#### Database operations + +Database operations are handled by helper functions in `utils/db.py`. Key functions include: + +- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) +- `get_connection_url()`: Creates a database connection URL from environment variables in `.env` +- `get_session()`: Provides a database session for performing operations + +To perform database operations in route handlers, inject the database session as a dependency: + +```python +@app.get("/users") +async def get_users(session: Session = Depends(get_session)): + users = session.exec(select(User)).all() + return users +``` + +The session automatically handles transaction management, ensuring that database operations are atomic and consistent. + diff --git a/migrations/set_up_db.py b/migrations/set_up_db.py deleted file mode 100644 index 5e7f7e9..0000000 --- a/migrations/set_up_db.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -import os - -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - -if __name__ == "__main__": - import argparse - import os - from dotenv import load_dotenv - from utils.db import set_up_db - - load_dotenv() - - parser = argparse.ArgumentParser( - description="Optionally drop and recreate all tables in the database and set up default roles and permissions" - ) - parser.add_argument( - "--drop", - action="store_true", - help="Drop all tables first" - ) - - args = parser.parse_args() - - set_up_db(args.drop) - - print( - f"Set up database {os.getenv('DB_NAME')}" - ) diff --git a/tests/conftest.py b/tests/conftest.py index d356ad0..da1eec2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,11 @@ import pytest +from dotenv import load_dotenv from sqlmodel import create_engine, Session, delete -from utils.db import get_connection_url, set_up_db, tear_down_db +from fastapi.testclient import TestClient +from utils.db import get_connection_url, set_up_db, tear_down_db, get_session from utils.models import User, PasswordResetToken -from dotenv import load_dotenv +from utils.auth import get_password_hash +from main import app load_dotenv() @@ -49,3 +52,36 @@ def clean_db(session: Session): session.exec(delete(User)) # type: ignore session.commit() + + +# Test client fixture +@pytest.fixture() +def client(session: Session): + """ + Provides a TestClient instance with the session fixture. + Overrides the get_session dependency to use the test session. + """ + def get_session_override(): + return session + + app.dependency_overrides[get_session] = get_session_override + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +# Test user fixture +@pytest.fixture() +def test_user(session: Session): + """ + Creates a test user in the database. + """ + user = User( + name="Test User", + email="test@example.com", + hashed_password=get_password_hash("Test123!@#") + ) + session.add(user) + session.commit() + session.refresh(user) + return user diff --git a/tests/test_authentication.py b/tests/test_authentication.py index eed349e..ac0df9e 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -9,7 +9,6 @@ from main import app from utils.models import User, PasswordResetToken -from utils.db import get_session from utils.auth import ( create_access_token, create_refresh_token, @@ -23,39 +22,6 @@ # --- Fixture setup --- -# Test client fixture -@pytest.fixture(name="client") -def client_fixture(session: Session): - """ - Provides a TestClient instance with the session fixture. - Overrides the get_session dependency to use the test session. - """ - def get_session_override(): - return session - - app.dependency_overrides[get_session] = get_session_override - client = TestClient(app) - yield client - app.dependency_overrides.clear() - - -# Test user fixture -@pytest.fixture(name="test_user") -def test_user_fixture(session: Session): - """ - Creates a test user in the database. - """ - user = User( - name="Test User", - email="test@example.com", - hashed_password=get_password_hash("Test123!@#") - ) - session.add(user) - session.commit() - session.refresh(user) - return user - - # Mock email response fixture @pytest.fixture def mock_email_response(): From 110c25a2f0c40f75a90fb09d2b8218f94d2e3f1d Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 00:57:42 +0000 Subject: [PATCH 12/73] Added note on client-side form validation --- docs/customization.qmd | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/customization.qmd b/docs/customization.qmd index f5b0f29..3ad4c21 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -111,6 +111,18 @@ async def welcome(request: Request): In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. +#### Form validation strategy + +While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. + +Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: +- Provide immediate feedback to users +- Reduce server load +- Improve user experience by avoiding round-trips to the server +- Prevent malformed data from ever reaching the backend + +Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example. + ### Writing type annotated code Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. From 8bac936d0acbf6a9c12e29a078dc90e8eb4b71ec Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 01:25:11 +0000 Subject: [PATCH 13/73] Database schema ERD --- docs/customization.qmd | 37 +++++++--- docs/static/schema.png | Bin 0 -> 53704 bytes poetry.lock | 150 ++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 4 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 docs/static/schema.png diff --git a/docs/customization.qmd b/docs/customization.qmd index 3ad4c21..d032cac 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -121,7 +121,7 @@ Best practices dictate implementing thorough client-side validation via JavaScri - Improve user experience by avoiding round-trips to the server - Prevent malformed data from ever reaching the backend -Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example. +Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. ### Writing type annotated code @@ -236,16 +236,34 @@ Our database models are defined in `utils/models.py`. Each model is a Python cla - `RolePermissionLink`: Maps roles to their allowed permissions - `PasswordResetToken`: Manages password reset functionality -Models can have relationships with other models using SQLModel's `Relationship` field. For example: - -```python -class User(SQLModel, table=True): - # ... other fields ... - organization: Optional["Organization"] = Relationship(back_populates="users") - role: Optional["Role"] = Relationship(back_populates="users") +Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: + +```{python} +#| echo: false +#| warning: false +import sys +sys.path.append("..") +from utils.models import * +from utils.db import engine +from sqlalchemy import MetaData +from sqlalchemy_schemadisplay import create_schema_graph + +# Create the directed graph +graph = create_schema_graph( + engine=engine, + metadata=SQLModel.metadata, + show_datatypes=True, + show_indexes=True, + rankdir='TB', + concentrate=False +) + +# Save the graph +graph.write_png('static/schema.png') ``` -This creates a many-to-one relationship between users and organizations, and between users and roles. +![Database Schema](static/schema.png) + #### Database operations @@ -265,4 +283,3 @@ async def get_users(session: Session = Depends(get_session)): ``` The session automatically handles transaction management, ensuring that database operations are atomic and consistent. - diff --git a/docs/static/schema.png b/docs/static/schema.png new file mode 100644 index 0000000000000000000000000000000000000000..0656c093ae963491c3b20f5ae6013c1851ad38fd GIT binary patch literal 53704 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 literal 0 HcmV?d00001 diff --git a/poetry.lock b/poetry.lock index 204263b..f0bad51 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1657,6 +1657,98 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pillow" +version = "11.0.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, + {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, + {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, + {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, + {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, + {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, + {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, + {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, + {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, + {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, + {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, + {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, + {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, + {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, + {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, + {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, + {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, + {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, + {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, + {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, + {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, + {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, + {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, + {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, + {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, + {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, + {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, + {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, + {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, + {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, + {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, + {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, + {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, + {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, + {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, + {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, + {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, + {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, + {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, + {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, + {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, + {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, + {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, + {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, + {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, + {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, + {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, + {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, + {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, + {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, + {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.6" @@ -1925,6 +2017,25 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydot" +version = "3.0.2" +description = "Python interface to Graphviz's Dot" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydot-3.0.2-py3-none-any.whl", hash = "sha256:99cedaa55d04abb0b2bc56d9981a6da781053dd5ac75c428e8dd53db53f90b14"}, + {file = "pydot-3.0.2.tar.gz", hash = "sha256:9180da540b51b3aa09fbf81140b3edfbe2315d778e8589a7d0a4a69c41332bae"}, +] + +[package.dependencies] +pyparsing = ">=3.0.9" + +[package.extras] +dev = ["chardet", "parameterized", "ruff"] +release = ["zest.releaser[recommended]"] +tests = ["chardet", "parameterized", "pytest", "pytest-cov", "pytest-xdist[psutil]", "ruff", "tox"] + [[package]] name = "pygments" version = "2.18.0" @@ -1956,6 +2067,20 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pyparsing" +version = "3.2.0" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, + {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "8.3.3" @@ -2608,6 +2733,27 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "sqlalchemy-schemadisplay" +version = "2.0" +description = "Package for the generation of diagrams based on SQLAlchemy ORM models and or the database itself" +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqlalchemy_schemadisplay-2.0-py3-none-any.whl", hash = "sha256:e4b928e2aec145f72a2b35de7855a78fca5e09ac4d48f2d58b4472cb640cd362"}, + {file = "sqlalchemy_schemadisplay-2.0.tar.gz", hash = "sha256:e90b9c9868814975d674a889aadb7c4651658f0e119e1c9320279ea527744d5e"}, +] + +[package.dependencies] +Pillow = "*" +pydot = "*" +setuptools = "*" +sqlalchemy = ">=2.0,<3" + +[package.extras] +pre-commit = ["pre-commit", "tox (>=3.23.0)", "virtualenv (>20)"] +testing = ["attrs (>=17.4.0)", "coverage", "pgtest", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"] + [[package]] name = "sqlmodel" version = "0.0.22" @@ -2867,4 +3013,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "6df2fda56fc038f368178ffc6f8671af88a5e38678c91509fe89d164f0dfc513" +content-hash = "c7fcb0eef73f683768a13906716650b17b960ed6e5bfec4ed2b015691b517ea6" diff --git a/pyproject.toml b/pyproject.toml index 622c372..c9c58ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ mypy = "^1.11.2" jupyter = "^1.1.1" notebook = "^7.2.2" pytest = "^8.3.3" +sqlalchemy-schemadisplay = "^2.0" [build-system] requires = ["poetry-core"] From 9beee0e86931c85e2f59105f385bb4d202822fea Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 02:05:55 +0000 Subject: [PATCH 14/73] Customization adjustments --- docs/customization.qmd | 285 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/docs/customization.qmd b/docs/customization.qmd index a3dfe2e..279bbaf 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -2,3 +2,288 @@ title: "Customization" --- +## Development workflow + +### Dependency management with Poetry + +The project uses Poetry to manage dependencies: + +- Add new dependency: `poetry add ` +- Add development dependency: `poetry add --dev ` +- Remove dependency: `poetry remove ` +- Update lock file: `poetry lock` +- Install dependencies: `poetry install` +- Update all dependencies: `poetry update` + +### Testing + +The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! + +The following fixtures, defined in `tests/conftest.py`, are available in the test suite: + +- `engine`: Creates a new SQLModel engine for the test database. +- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. +- `session`: Provides a session for database operations in tests. +- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. +- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session. +- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. + +To run the tests, use these commands: + +- Run all tests: `pytest` +- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` +- Run particular test files by name: `pytest ` +- Run particular tests by name: `pytest -k ` + +### Type checking with mypy + +The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: + +```bash +mypy +``` + +We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change! + +## Project structure + +### Customizable folders and files + +- FastAPI application entry point and GET routes: `main.py` +- FastAPI POST routes: `routers/` + - User authentication endpoints: `auth.py` + - User profile management endpoints: `user.py` + - Organization management endpoints: `organization.py` + - Role management endpoints: `role.py` +- Jinja2 templates: `templates/` +- Static assets: `static/` +- Unit tests: `tests/` +- Test database configuration: `docker-compose.yml` +- Helper functions: `utils/` + - Auth helpers: `auth.py` + - Database helpers: `db.py` + - Database models: `models.py` +- Environment variables: `.env` +- CI/CD configuration: `.github/` +- Project configuration: `pyproject.toml` +- Quarto documentation: + - Source: `index.qmd` + `docs/` + - Configuration: `_quarto.yml` + +Most everything else is auto-generated and should not be manually modified. + +### Defining a web backend with FastAPI + +We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. + +We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) + +#### Routing patterns in this template + +In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. + +We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. + +We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: + +```python +# -- Authenticated Routes -- +``` + +Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. + +Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. + +### HTML templating with Jinja2 + +To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. + +With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. + +#### Context variables + +Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: + +```python +@app.get("/welcome") +async def welcome(request: Request): + return templates.TemplateResponse( + "welcome.html", + {"username": "Alice"} + ) +``` + +In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. + +#### Form validation strategy + +While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. + +Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: +- Provide immediate feedback to users +- Reduce server load +- Improve user experience by avoiding round-trips to the server +- Prevent malformed data from ever reaching the backend + +Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. + +### Writing type annotated code + +Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. + +If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. + +For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. + +#### Defining request models and custom validators + +For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: + +```python +class PasswordMismatchError(HTTPException): + def __init__(self, field: str = "confirm_password"): + super().__init__( + status_code=422, + detail={ + "field": field, + "message": "The passwords you entered do not match" + } + ) + +class UserRegister(BaseModel): + name: str + email: EmailStr + password: str + confirm_password: str + + # Custom validators are added as class attributes + @field_validator("confirm_password", check_fields=False) + def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: + if v != values["password"]: + raise PasswordMismatchError() + return v + # ... +``` + +We then add this request model as a dependency in the signature of our POST route: + +```python +@app.post("/register") +async def register(request: UserRegister = Depends()): + # ... +``` + +When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. + +(Note that these examples are simplified versions of the actual code.) + +#### Converting form data to request models + +In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: + +```python +class UserRegister(BaseModel): + # ... + + @classmethod + async def as_form( + cls, + name: str = Form(...), + email: EmailStr = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) + ): + return cls( + name=name, + email=email, + password=password, + confirm_password=confirm_password + ) +``` + +#### Middleware exception handling + +Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. + +This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses. + +Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific. + +Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: + +```python +@app.exception_handler(PasswordMismatchError) +async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): + return templates.TemplateResponse( + request, + "errors/validation_error.html", + { + "status_code": 422, + "errors": {"error": exc.detail} + }, + status_code=422, + ) +``` + +### Database configuration and access with SQLModel + +SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. + +#### Models and relationships + +Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: + +- `Organization`: Represents a company or team +- `User`: Represents a user account +- `Role`: Represents a discrete set of user permissions within an organization +- `Permission`: Represents specific actions a user can perform +- `RolePermissionLink`: Maps roles to their allowed permissions +- `PasswordResetToken`: Manages password reset functionality + +Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: + +```{python} +#| echo: false +#| warning: false +import sys +sys.path.append("..") +from utils.models import * +from utils.db import engine +from sqlalchemy import MetaData +from sqlalchemy_schemadisplay import create_schema_graph + +# Create the directed graph +graph = create_schema_graph( + engine=engine, + metadata=SQLModel.metadata, + show_datatypes=True, + show_indexes=True, + rankdir='TB', + concentrate=False +) + +# Save the graph +graph.write_png('static/schema.png') +``` + +![Database Schema](static/schema.png) + + +#### Database operations + +Database operations are handled by helper functions in `utils/db.py`. Key functions include: + +- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) +- `get_connection_url()`: Creates a database connection URL from environment variables in `.env` +- `get_session()`: Provides a database session for performing operations + +To perform database operations in route handlers, inject the database session as a dependency: + +```python +@app.get("/users") +async def get_users(session: Session = Depends(get_session)): + users = session.exec(select(User)).all() + return users +``` + +The session automatically handles transaction management, ensuring that database operations are atomic and consistent. From 4ea65097f929260a11822a95582f8581ba2e0a4c Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 22:10:23 +0000 Subject: [PATCH 15/73] Don't return users with deleted column set to True --- routers/authentication.py | 26 +++++++-- routers/user.py | 47 +++++++++++----- templates/users/profile.html | 2 +- tests/test_authentication.py | 103 +++++++++++++++++++++++++++++++++++ utils/auth.py | 15 ++++- utils/models.py | 13 ++++- 6 files changed, 179 insertions(+), 27 deletions(-) diff --git a/routers/authentication.py b/routers/authentication.py index 0a1098b..5d75e1f 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -1,5 +1,5 @@ # auth.py -from logging import getLogger +from logging import getLogger, DEBUG from typing import Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form @@ -22,6 +22,7 @@ ) logger = getLogger("uvicorn.error") +logger.setLevel(DEBUG) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -126,7 +127,9 @@ async def register( session: Session = Depends(get_session), ) -> RedirectResponse: db_user = session.exec(select(User).where( - User.email == user.email)).first() + User.email == user.email, + User.deleted == False + )).first() if db_user: raise HTTPException(status_code=400, detail="Email already registered") @@ -156,7 +159,9 @@ async def login( session: Session = Depends(get_session), ) -> RedirectResponse: db_user = session.exec(select(User).where( - User.email == user.email)).first() + User.email == user.email, + User.deleted == False + )).first() if not db_user or not verify_password(user.password, db_user.hashed_password): raise HTTPException(status_code=400, detail="Invalid credentials") @@ -205,7 +210,9 @@ async def refresh_token( user_email = decoded_token.get("sub") db_user = session.exec(select(User).where( - User.email == user_email)).first() + User.email == user_email, + User.deleted == False + )).first() if not db_user: return RedirectResponse(url="/login", status_code=303) @@ -239,7 +246,9 @@ async def forgot_password( session: Session = Depends(get_session) ): db_user = session.exec(select(User).where( - User.email == user.email)).first() + User.email == user.email, + User.deleted == False + )).first() if db_user: background_tasks.add_task(send_reset_email, user.email, session) @@ -255,8 +264,13 @@ async def reset_password( authorized_user, reset_token = get_user_from_reset_token( user.email, user.token, session) - if not authorized_user or not reset_token: + logger.debug(f"authorized_user: {authorized_user}") + logger.debug(f"reset_token: {reset_token}") + + if not reset_token: raise HTTPException(status_code=400, detail="Invalid or expired token") + elif not authorized_user: + raise HTTPException(status_code=400, detail="User not found") # Update password and mark token as used authorized_user.hashed_password = get_password_hash(user.new_password) diff --git a/routers/user.py b/routers/user.py index 86d3710..4d360fa 100644 --- a/routers/user.py +++ b/routers/user.py @@ -37,6 +37,22 @@ async def as_form( return cls(confirm_delete_password=confirm_delete_password) +class UpdateProfile(BaseModel): + """Request model for updating user profile information""" + name: str + email: EmailStr + avatar_url: str + + @classmethod + async def as_form( + cls, + name: str = Form(...), + email: EmailStr = Form(...), + avatar_url: str = Form(...), + ): + return cls(name=name, email=email, avatar_url=avatar_url) + + # -- Routes -- @@ -48,18 +64,16 @@ async def view_profile( return {"user": current_user} -@router.post("/edit_profile", response_class=RedirectResponse) -async def edit_profile( - name: str = Form(...), - email: str = Form(...), - avatar_url: str = Form(...), +@router.post("/update_profile", response_class=RedirectResponse) +async def update_profile( + profile_update: UpdateProfile = Depends(UpdateProfile.as_form), current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Update user details - current_user.name = name - current_user.email = email - current_user.avatar_url = avatar_url + current_user.name = profile_update.name + current_user.email = profile_update.email + current_user.avatar_url = profile_update.avatar_url session.commit() session.refresh(current_user) return RedirectResponse(url="/profile", status_code=303) @@ -67,18 +81,21 @@ async def edit_profile( @router.post("/delete_account", response_class=RedirectResponse) async def delete_account( - confirm_delete_password: str = Form(...), + user_delete_account: UserDeleteAccount = Depends( + UserDeleteAccount.as_form), current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): - if not verify_password(confirm_delete_password, current_user.hashed_password): + if not verify_password(user_delete_account.confirm_delete_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="Password is incorrect") # Mark the user as deleted current_user.deleted = True session.commit() - #Logs Out - router.get("/logout", response_class=RedirectResponse) - # Deletes user - session.delete(current_user) - return RedirectResponse(url="/", status_code=303) + + # Delete the user's access and refresh tokens to force a logout + response = RedirectResponse(url="/", status_code=303) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + + return response diff --git a/templates/users/profile.html b/templates/users/profile.html index 5afa794..448c0d6 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -34,7 +34,7 @@

User Profile

Edit Profile
-
+
diff --git a/tests/test_authentication.py b/tests/test_authentication.py index eed349e..0e749fa 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -261,6 +261,10 @@ def test_logout_endpoint(client: TestClient): def test_register_with_existing_email(client: TestClient, test_user: User): + """Test that registration fails with an existing non-deleted user's email""" + # Ensure test user is not deleted + assert not test_user.deleted + response = client.post( "/auth/register", data={ @@ -273,6 +277,34 @@ def test_register_with_existing_email(client: TestClient, test_user: User): assert response.status_code == 400 +def test_register_with_deleted_user_email(client: TestClient, test_user: User, session: Session): + """Test that registration succeeds with a deleted user's email""" + # Mark test user as deleted + test_user.deleted = True + session.add(test_user) + session.commit() + + response = client.post( + "/auth/register", + data={ + "name": "New User", + "email": test_user.email, + "password": "Test123!@#", + "confirm_password": "Test123!@#" + }, + follow_redirects=False + ) + assert response.status_code == 303 + + # Verify new user was created + new_user = session.exec(select(User).where( + User.email == test_user.email, + User.deleted == False + )).first() + assert new_user is not None + assert new_user.id != test_user.id + + def test_login_with_invalid_credentials(client: TestClient, test_user: User): response = client.post( "/auth/login", @@ -361,3 +393,74 @@ def test_password_reset_email_url(client: TestClient, session: Session, test_use assert parsed.path == str(reset_password_path) assert query_params["email"][0] == test_user.email assert query_params["token"][0] == reset_token.token + + +def test_deleted_user_cannot_login(client: TestClient, test_user: User, session: Session): + """Test that a deleted user cannot log in""" + # First mark the user as deleted + test_user.deleted = True + session.add(test_user) + session.commit() + + response = client.post( + "/auth/login", + data={ + "email": test_user.email, + "password": "Test123!@#" + } + ) + assert response.status_code == 400 + + +def test_deleted_user_cannot_use_tokens(client: TestClient, test_user: User, session: Session): + """Test that a deleted user's tokens become invalid""" + # Create tokens before marking user as deleted + access_token = create_access_token({"sub": test_user.email}) + refresh_token = create_refresh_token({"sub": test_user.email}) + + # Mark user as deleted + test_user.deleted = True + session.add(test_user) + session.commit() + + # Set tokens in cookies + client.cookies.set("access_token", access_token) + client.cookies.set("refresh_token", refresh_token) + + # Try to refresh tokens + response = client.post("/auth/refresh", follow_redirects=False) + assert response.status_code == 303 # user is redirected to login + + +def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User): + """Test that a deleted user cannot use a previously issued reset token""" + # First create a reset token + response = client.post( + "/auth/forgot_password", + data={"email": test_user.email}, + follow_redirects=False + ) + assert response.status_code == 303 + + # Get the reset token + reset_token = session.exec(select(PasswordResetToken) + .where(PasswordResetToken.user_id == test_user.id)).first() + assert reset_token is not None + + # Now mark user as deleted + test_user.deleted = True + session.add(test_user) + session.commit() + + # Try to use the reset token + response = client.post( + "/auth/reset_password", + data={ + "email": test_user.email, + "token": reset_token.token, + "new_password": "NewPass123!@#", + "confirm_new_password": "NewPass123!@#" + }, + follow_redirects=False + ) + assert response.status_code == 400 diff --git a/utils/auth.py b/utils/auth.py index 3bf7dac..a5b956c 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -180,7 +180,9 @@ def validate_token_and_get_user( if decoded_token: user_email = decoded_token.get("sub") user = session.exec(select(User).where( - User.email == user_email)).first() + User.email == user_email, + User.deleted == False + )).first() if user: if token_type == "refresh": new_access_token = create_access_token( @@ -275,7 +277,10 @@ def generate_password_reset_url(email: str, token: str) -> str: def send_reset_email(email: str, session: Session): # Check for an existing unexpired token - user = session.exec(select(User).where(User.email == email)).first() + user = session.exec(select(User).where( + User.email == email, + User.deleted == False + )).first() if user: existing_token = session.exec( select(PasswordResetToken) @@ -327,7 +332,11 @@ def get_user_from_reset_token(email: str, token: str, session: Session) -> tuple user = session.exec(select(User).where( User.email == email, - User.id == reset_token.user_id + User.id == reset_token.user_id, + User.deleted == False )).first() + if not user: + return None, None + return user, reset_token diff --git a/utils/models.py b/utils/models.py index 5941f3f..902269b 100644 --- a/utils/models.py +++ b/utils/models.py @@ -3,7 +3,7 @@ from datetime import datetime, UTC, timedelta from typing import Optional, List from sqlmodel import SQLModel, Field, Relationship -from sqlalchemy import Column, Enum as SQLAlchemyEnum +from sqlalchemy import Column, Enum as SQLAlchemyEnum, Index def utc_time(): @@ -89,7 +89,7 @@ class PasswordResetToken(SQLModel, table=True): class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str - email: str = Field(index=True, unique=True) + email: str = Field(index=True) hashed_password: str avatar_url: Optional[str] = None organization_id: Optional[int] = Field( @@ -105,6 +105,15 @@ class User(SQLModel, table=True): password_reset_tokens: List["PasswordResetToken"] = Relationship( back_populates="user") + __table_args__ = ( + Index( + 'ix_user_email_unique_active', + 'email', + unique=True, + postgresql_where=(deleted == False) + ), + ) + class UserOrganizationLink(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) From 30551b7ef435ec22247004a84d63856fd341b703 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 22:21:29 +0000 Subject: [PATCH 16/73] Use a mock to send email in unit test --- tests/test_authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 0e749fa..35f4144 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -432,7 +432,7 @@ def test_deleted_user_cannot_use_tokens(client: TestClient, test_user: User, ses assert response.status_code == 303 # user is redirected to login -def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User): +def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User, mock_resend_send): """Test that a deleted user cannot use a previously issued reset token""" # First create a reset token response = client.post( From e058660edd4757bba71de50be2b0d9503ee8ce6b Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 22:33:45 +0000 Subject: [PATCH 17/73] Revert "Use a mock to send email in unit test" This reverts commit 30551b7ef435ec22247004a84d63856fd341b703. --- tests/test_authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 35f4144..0e749fa 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -432,7 +432,7 @@ def test_deleted_user_cannot_use_tokens(client: TestClient, test_user: User, ses assert response.status_code == 303 # user is redirected to login -def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User, mock_resend_send): +def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User): """Test that a deleted user cannot use a previously issued reset token""" # First create a reset token response = client.post( From b603e734399a05006925aef93dc0bea891d26ad6 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 22:33:46 +0000 Subject: [PATCH 18/73] Revert "Don't return users with deleted column set to True" This reverts commit 4ea65097f929260a11822a95582f8581ba2e0a4c. --- routers/authentication.py | 26 ++------- routers/user.py | 47 +++++----------- templates/users/profile.html | 2 +- tests/test_authentication.py | 103 ----------------------------------- utils/auth.py | 15 +---- utils/models.py | 13 +---- 6 files changed, 27 insertions(+), 179 deletions(-) diff --git a/routers/authentication.py b/routers/authentication.py index 5d75e1f..0a1098b 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -1,5 +1,5 @@ # auth.py -from logging import getLogger, DEBUG +from logging import getLogger from typing import Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form @@ -22,7 +22,6 @@ ) logger = getLogger("uvicorn.error") -logger.setLevel(DEBUG) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -127,9 +126,7 @@ async def register( session: Session = Depends(get_session), ) -> RedirectResponse: db_user = session.exec(select(User).where( - User.email == user.email, - User.deleted == False - )).first() + User.email == user.email)).first() if db_user: raise HTTPException(status_code=400, detail="Email already registered") @@ -159,9 +156,7 @@ async def login( session: Session = Depends(get_session), ) -> RedirectResponse: db_user = session.exec(select(User).where( - User.email == user.email, - User.deleted == False - )).first() + User.email == user.email)).first() if not db_user or not verify_password(user.password, db_user.hashed_password): raise HTTPException(status_code=400, detail="Invalid credentials") @@ -210,9 +205,7 @@ async def refresh_token( user_email = decoded_token.get("sub") db_user = session.exec(select(User).where( - User.email == user_email, - User.deleted == False - )).first() + User.email == user_email)).first() if not db_user: return RedirectResponse(url="/login", status_code=303) @@ -246,9 +239,7 @@ async def forgot_password( session: Session = Depends(get_session) ): db_user = session.exec(select(User).where( - User.email == user.email, - User.deleted == False - )).first() + User.email == user.email)).first() if db_user: background_tasks.add_task(send_reset_email, user.email, session) @@ -264,13 +255,8 @@ async def reset_password( authorized_user, reset_token = get_user_from_reset_token( user.email, user.token, session) - logger.debug(f"authorized_user: {authorized_user}") - logger.debug(f"reset_token: {reset_token}") - - if not reset_token: + if not authorized_user or not reset_token: raise HTTPException(status_code=400, detail="Invalid or expired token") - elif not authorized_user: - raise HTTPException(status_code=400, detail="User not found") # Update password and mark token as used authorized_user.hashed_password = get_password_hash(user.new_password) diff --git a/routers/user.py b/routers/user.py index 4d360fa..86d3710 100644 --- a/routers/user.py +++ b/routers/user.py @@ -37,22 +37,6 @@ async def as_form( return cls(confirm_delete_password=confirm_delete_password) -class UpdateProfile(BaseModel): - """Request model for updating user profile information""" - name: str - email: EmailStr - avatar_url: str - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - avatar_url: str = Form(...), - ): - return cls(name=name, email=email, avatar_url=avatar_url) - - # -- Routes -- @@ -64,16 +48,18 @@ async def view_profile( return {"user": current_user} -@router.post("/update_profile", response_class=RedirectResponse) -async def update_profile( - profile_update: UpdateProfile = Depends(UpdateProfile.as_form), +@router.post("/edit_profile", response_class=RedirectResponse) +async def edit_profile( + name: str = Form(...), + email: str = Form(...), + avatar_url: str = Form(...), current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Update user details - current_user.name = profile_update.name - current_user.email = profile_update.email - current_user.avatar_url = profile_update.avatar_url + current_user.name = name + current_user.email = email + current_user.avatar_url = avatar_url session.commit() session.refresh(current_user) return RedirectResponse(url="/profile", status_code=303) @@ -81,21 +67,18 @@ async def update_profile( @router.post("/delete_account", response_class=RedirectResponse) async def delete_account( - user_delete_account: UserDeleteAccount = Depends( - UserDeleteAccount.as_form), + confirm_delete_password: str = Form(...), current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): - if not verify_password(user_delete_account.confirm_delete_password, current_user.hashed_password): + if not verify_password(confirm_delete_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="Password is incorrect") # Mark the user as deleted current_user.deleted = True session.commit() - - # Delete the user's access and refresh tokens to force a logout - response = RedirectResponse(url="/", status_code=303) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - - return response + #Logs Out + router.get("/logout", response_class=RedirectResponse) + # Deletes user + session.delete(current_user) + return RedirectResponse(url="/", status_code=303) diff --git a/templates/users/profile.html b/templates/users/profile.html index 448c0d6..5afa794 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -34,7 +34,7 @@

User Profile

Edit Profile
- +
diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 0e749fa..eed349e 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -261,10 +261,6 @@ def test_logout_endpoint(client: TestClient): def test_register_with_existing_email(client: TestClient, test_user: User): - """Test that registration fails with an existing non-deleted user's email""" - # Ensure test user is not deleted - assert not test_user.deleted - response = client.post( "/auth/register", data={ @@ -277,34 +273,6 @@ def test_register_with_existing_email(client: TestClient, test_user: User): assert response.status_code == 400 -def test_register_with_deleted_user_email(client: TestClient, test_user: User, session: Session): - """Test that registration succeeds with a deleted user's email""" - # Mark test user as deleted - test_user.deleted = True - session.add(test_user) - session.commit() - - response = client.post( - "/auth/register", - data={ - "name": "New User", - "email": test_user.email, - "password": "Test123!@#", - "confirm_password": "Test123!@#" - }, - follow_redirects=False - ) - assert response.status_code == 303 - - # Verify new user was created - new_user = session.exec(select(User).where( - User.email == test_user.email, - User.deleted == False - )).first() - assert new_user is not None - assert new_user.id != test_user.id - - def test_login_with_invalid_credentials(client: TestClient, test_user: User): response = client.post( "/auth/login", @@ -393,74 +361,3 @@ def test_password_reset_email_url(client: TestClient, session: Session, test_use assert parsed.path == str(reset_password_path) assert query_params["email"][0] == test_user.email assert query_params["token"][0] == reset_token.token - - -def test_deleted_user_cannot_login(client: TestClient, test_user: User, session: Session): - """Test that a deleted user cannot log in""" - # First mark the user as deleted - test_user.deleted = True - session.add(test_user) - session.commit() - - response = client.post( - "/auth/login", - data={ - "email": test_user.email, - "password": "Test123!@#" - } - ) - assert response.status_code == 400 - - -def test_deleted_user_cannot_use_tokens(client: TestClient, test_user: User, session: Session): - """Test that a deleted user's tokens become invalid""" - # Create tokens before marking user as deleted - access_token = create_access_token({"sub": test_user.email}) - refresh_token = create_refresh_token({"sub": test_user.email}) - - # Mark user as deleted - test_user.deleted = True - session.add(test_user) - session.commit() - - # Set tokens in cookies - client.cookies.set("access_token", access_token) - client.cookies.set("refresh_token", refresh_token) - - # Try to refresh tokens - response = client.post("/auth/refresh", follow_redirects=False) - assert response.status_code == 303 # user is redirected to login - - -def test_deleted_user_cannot_use_reset_token(client: TestClient, session: Session, test_user: User): - """Test that a deleted user cannot use a previously issued reset token""" - # First create a reset token - response = client.post( - "/auth/forgot_password", - data={"email": test_user.email}, - follow_redirects=False - ) - assert response.status_code == 303 - - # Get the reset token - reset_token = session.exec(select(PasswordResetToken) - .where(PasswordResetToken.user_id == test_user.id)).first() - assert reset_token is not None - - # Now mark user as deleted - test_user.deleted = True - session.add(test_user) - session.commit() - - # Try to use the reset token - response = client.post( - "/auth/reset_password", - data={ - "email": test_user.email, - "token": reset_token.token, - "new_password": "NewPass123!@#", - "confirm_new_password": "NewPass123!@#" - }, - follow_redirects=False - ) - assert response.status_code == 400 diff --git a/utils/auth.py b/utils/auth.py index a5b956c..3bf7dac 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -180,9 +180,7 @@ def validate_token_and_get_user( if decoded_token: user_email = decoded_token.get("sub") user = session.exec(select(User).where( - User.email == user_email, - User.deleted == False - )).first() + User.email == user_email)).first() if user: if token_type == "refresh": new_access_token = create_access_token( @@ -277,10 +275,7 @@ def generate_password_reset_url(email: str, token: str) -> str: def send_reset_email(email: str, session: Session): # Check for an existing unexpired token - user = session.exec(select(User).where( - User.email == email, - User.deleted == False - )).first() + user = session.exec(select(User).where(User.email == email)).first() if user: existing_token = session.exec( select(PasswordResetToken) @@ -332,11 +327,7 @@ def get_user_from_reset_token(email: str, token: str, session: Session) -> tuple user = session.exec(select(User).where( User.email == email, - User.id == reset_token.user_id, - User.deleted == False + User.id == reset_token.user_id )).first() - if not user: - return None, None - return user, reset_token diff --git a/utils/models.py b/utils/models.py index 902269b..5941f3f 100644 --- a/utils/models.py +++ b/utils/models.py @@ -3,7 +3,7 @@ from datetime import datetime, UTC, timedelta from typing import Optional, List from sqlmodel import SQLModel, Field, Relationship -from sqlalchemy import Column, Enum as SQLAlchemyEnum, Index +from sqlalchemy import Column, Enum as SQLAlchemyEnum def utc_time(): @@ -89,7 +89,7 @@ class PasswordResetToken(SQLModel, table=True): class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str - email: str = Field(index=True) + email: str = Field(index=True, unique=True) hashed_password: str avatar_url: Optional[str] = None organization_id: Optional[int] = Field( @@ -105,15 +105,6 @@ class User(SQLModel, table=True): password_reset_tokens: List["PasswordResetToken"] = Relationship( back_populates="user") - __table_args__ = ( - Index( - 'ix_user_email_unique_active', - 'email', - unique=True, - postgresql_where=(deleted == False) - ), - ) - class UserOrganizationLink(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) From fbcb5504ab2541efc034652ab6fc0214e4237936 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 24 Nov 2024 22:46:49 +0000 Subject: [PATCH 19/73] Remove deleted attributes in the database models and actually delete the records instead --- routers/authentication.py | 1 - routers/organization.py | 5 +---- routers/role.py | 10 +++------- routers/user.py | 12 +++++------- tests/test_user.py | 6 ++++++ utils/models.py | 12 ++++++------ 6 files changed, 21 insertions(+), 25 deletions(-) create mode 100644 tests/test_user.py diff --git a/routers/authentication.py b/routers/authentication.py index 0a1098b..d487575 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -114,7 +114,6 @@ class UserRead(BaseModel): organization_id: Optional[int] created_at: datetime updated_at: datetime - deleted: bool # -- Routes -- diff --git a/routers/organization.py b/routers/organization.py index 9d4f1de..ea48a94 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -27,7 +27,6 @@ class OrganizationRead(BaseModel): name: str created_at: datetime updated_at: datetime - deleted: bool class OrganizationUpdate(BaseModel): @@ -113,9 +112,7 @@ def delete_organization( if not db_org: raise HTTPException(status_code=404, detail="Organization not found") - db_org.deleted = True - db_org.updated_at = datetime.utcnow() - session.add(db_org) + session.delete(db_org) session.commit() return RedirectResponse(url="/organizations", status_code=303) diff --git a/routers/role.py b/routers/role.py index cb2488f..a6429c4 100644 --- a/routers/role.py +++ b/routers/role.py @@ -31,7 +31,6 @@ class RoleRead(BaseModel): name: str created_at: datetime updated_at: datetime - deleted: bool permissions: List[ValidPermissions] @@ -74,7 +73,7 @@ def create_role( @router.get("/{role_id}", response_model=RoleRead) def read_role(role_id: int, session: Session = Depends(get_session)): db_role: Role | None = session.get(Role, role_id) - if not db_role or not db_role.id or db_role.deleted: + if not db_role or not db_role.id: raise HTTPException(status_code=404, detail="Role not found") permissions = [ @@ -88,7 +87,6 @@ def read_role(role_id: int, session: Session = Depends(get_session)): name=db_role.name, created_at=db_role.created_at, updated_at=db_role.updated_at, - deleted=db_role.deleted, permissions=permissions ) @@ -99,7 +97,7 @@ def update_role( session: Session = Depends(get_session) ) -> RedirectResponse: db_role: Role | None = session.get(Role, role.id) - if not db_role or not db_role.id or db_role.deleted: + if not db_role or not db_role.id: raise HTTPException(status_code=404, detail="Role not found") role_data = role.model_dump(exclude_unset=True) for key, value in role_data.items(): @@ -131,8 +129,6 @@ def delete_role( db_role = session.get(Role, role_id) if not db_role: raise HTTPException(status_code=404, detail="Role not found") - db_role.deleted = True - db_role.updated_at = utc_time() - session.add(db_role) + session.delete(db_role) session.commit() return RedirectResponse(url="/roles", status_code=303) diff --git a/routers/user.py b/routers/user.py index 86d3710..3e1ab4e 100644 --- a/routers/user.py +++ b/routers/user.py @@ -74,11 +74,9 @@ async def delete_account( if not verify_password(confirm_delete_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="Password is incorrect") - # Mark the user as deleted - current_user.deleted = True - session.commit() - #Logs Out - router.get("/logout", response_class=RedirectResponse) - # Deletes user + # Delete the user session.delete(current_user) - return RedirectResponse(url="/", status_code=303) + session.commit() + + # Log out the user + return RedirectResponse(url="/logout", status_code=303) diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..26c4dba --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,6 @@ +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from main import app +from utils.models import User diff --git a/utils/models.py b/utils/models.py index 5941f3f..d487395 100644 --- a/utils/models.py +++ b/utils/models.py @@ -29,7 +29,6 @@ class Organization(SQLModel, table=True): name: str created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - deleted: bool = Field(default=False) users: List["User"] = Relationship(back_populates="organization") @@ -41,7 +40,6 @@ class Role(SQLModel, table=True): default=None, foreign_key="organization.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - deleted: bool = Field(default=False) users: List["User"] = Relationship(back_populates="role") role_permission_links: List["RolePermissionLink"] = Relationship( @@ -54,7 +52,6 @@ class Permission(SQLModel, table=True): sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False))) created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - deleted: bool = Field(default=False) role_permission_links: List["RolePermissionLink"] = Relationship( back_populates="permission") @@ -83,7 +80,9 @@ class PasswordResetToken(SQLModel, table=True): used: bool = Field(default=False) user: Optional["User"] = Relationship( - back_populates="password_reset_tokens") + back_populates="password_reset_tokens", + sa_relationship_kwargs={"cascade": "all, delete-orphan"} + ) class User(SQLModel, table=True): @@ -97,13 +96,14 @@ class User(SQLModel, table=True): role_id: Optional[int] = Field(default=None, foreign_key="role.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - deleted: bool = Field(default=False) organization: Optional["Organization"] = Relationship( back_populates="users") role: Optional["Role"] = Relationship(back_populates="users") password_reset_tokens: List["PasswordResetToken"] = Relationship( - back_populates="user") + back_populates="user", + sa_relationship_kwargs={"cascade": "all, delete-orphan"} + ) class UserOrganizationLink(SQLModel, table=True): From 9d8c5b3b85d879f4147a24af67ba2191a58a3822 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:54:32 +0000 Subject: [PATCH 20/73] Added tests for a couple read routes in main.py --- tests/test_main.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test_main.py diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a8dd554 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,21 @@ +from fastapi.testclient import TestClient + +from utils.models import User +from main import app + + +def test_read_profile_unauthorized(unauth_client: TestClient): + """Test that unauthorized users cannot view profile""" + response = unauth_client.get(app.url_path_for( + "read_profile"), follow_redirects=False) + assert response.status_code == 303 # Redirect to login + assert response.headers["location"] == app.url_path_for("read_login") + + +def test_read_profile_authorized(auth_client: TestClient, test_user: User): + """Test that authorized users can view their profile""" + response = auth_client.get(app.url_path_for("read_profile")) + assert response.status_code == 200 + # Check that the response contains the expected HTML content + assert test_user.email in response.text + assert test_user.name in response.text From 4ed648addcd2fb7a94e5475b4e28aa7156e0b417 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:55:46 +0000 Subject: [PATCH 21/73] Added request models for user profile update --- routers/user.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/routers/user.py b/routers/user.py index 3e1ab4e..043509d 100644 --- a/routers/user.py +++ b/routers/user.py @@ -11,7 +11,8 @@ # -- Server Request and Response Models -- -class UserProfile(BaseModel): +class UpdateProfile(BaseModel): + """Request model for updating user profile information""" name: str email: EmailStr avatar_url: str @@ -40,26 +41,16 @@ async def as_form( # -- Routes -- -@router.get("/profile", response_class=RedirectResponse) -async def view_profile( - current_user: User = Depends(get_authenticated_user) -): - # Render the profile page with the current user's data - return {"user": current_user} - - -@router.post("/edit_profile", response_class=RedirectResponse) -async def edit_profile( - name: str = Form(...), - email: str = Form(...), - avatar_url: str = Form(...), +@router.post("/update_profile", response_class=RedirectResponse) +async def update_profile( + user_profile: UpdateProfile = Depends(UpdateProfile.as_form), current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Update user details - current_user.name = name - current_user.email = email - current_user.avatar_url = avatar_url + current_user.name = user_profile.name + current_user.email = user_profile.email + current_user.avatar_url = user_profile.avatar_url session.commit() session.refresh(current_user) return RedirectResponse(url="/profile", status_code=303) @@ -67,16 +58,23 @@ async def edit_profile( @router.post("/delete_account", response_class=RedirectResponse) async def delete_account( - confirm_delete_password: str = Form(...), + user_delete_account: UserDeleteAccount = Depends( + UserDeleteAccount.as_form), current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): - if not verify_password(confirm_delete_password, current_user.hashed_password): - raise HTTPException(status_code=400, detail="Password is incorrect") + if not verify_password( + user_delete_account.confirm_delete_password, + current_user.hashed_password + ): + raise HTTPException( + status_code=400, + detail="Password is incorrect" + ) # Delete the user session.delete(current_user) session.commit() # Log out the user - return RedirectResponse(url="/logout", status_code=303) + return RedirectResponse(url="/auth/logout", status_code=303) From 7ea6d5127e30ba630f22c5178d907c7324533c27 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:56:20 +0000 Subject: [PATCH 22/73] Fixed a SQLAlchemy model problem where cascade was going the wrong way --- utils/models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/utils/models.py b/utils/models.py index d487395..cd43f34 100644 --- a/utils/models.py +++ b/utils/models.py @@ -80,9 +80,7 @@ class PasswordResetToken(SQLModel, table=True): used: bool = Field(default=False) user: Optional["User"] = Relationship( - back_populates="password_reset_tokens", - sa_relationship_kwargs={"cascade": "all, delete-orphan"} - ) + back_populates="password_reset_tokens") class User(SQLModel, table=True): From 3cc67dd3f8ef67e47e6e6711d9186481fac89c6b Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:57:23 +0000 Subject: [PATCH 23/73] Redirect unauthed users with 303 rather than 307 so POST requests are changed to GET --- utils/auth.py | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/utils/auth.py b/utils/auth.py index 3bf7dac..8fbae0b 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -12,6 +12,7 @@ from datetime import UTC, datetime, timedelta from typing import Optional from fastapi import Depends, Cookie, HTTPException, status +from fastapi.responses import RedirectResponse from utils.db import get_session from utils.models import User, PasswordResetToken @@ -180,7 +181,8 @@ def validate_token_and_get_user( if decoded_token: user_email = decoded_token.get("sub") user = session.exec(select(User).where( - User.email == user_email)).first() + User.email == user_email + )).first() if user: if token_type == "refresh": new_access_token = create_access_token( @@ -215,6 +217,14 @@ def get_user_from_tokens( return None, None, None +class AuthenticationError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"} + ) + + def get_authenticated_user( tokens: tuple[Optional[str], Optional[str] ] = Depends(oauth2_scheme_cookie), @@ -228,11 +238,7 @@ def get_authenticated_user( raise NeedsNewTokens(user, new_access_token, new_refresh_token) return user - # If both tokens are invalid or missing, redirect to login - raise HTTPException( - status_code=status.HTTP_307_TEMPORARY_REDIRECT, - headers={"Location": "/login"} - ) + raise AuthenticationError() def get_optional_user( @@ -275,7 +281,9 @@ def generate_password_reset_url(email: str, token: str) -> str: def send_reset_email(email: str, session: Session): # Check for an existing unexpired token - user = session.exec(select(User).where(User.email == email)).first() + user = session.exec(select(User).where( + User.email == email + )).first() if user: existing_token = session.exec( select(PasswordResetToken) @@ -316,18 +324,19 @@ def send_reset_email(email: str, session: Session): def get_user_from_reset_token(email: str, token: str, session: Session) -> tuple[Optional[User], Optional[PasswordResetToken]]: - reset_token = session.exec(select(PasswordResetToken).where( - PasswordResetToken.token == token, - PasswordResetToken.expires_at > datetime.now(UTC), - PasswordResetToken.used == False - )).first() + result = session.exec( + select(User, PasswordResetToken) + .where( + User.email == email, + PasswordResetToken.token == token, + PasswordResetToken.expires_at > datetime.now(UTC), + PasswordResetToken.used == False, + PasswordResetToken.user_id == User.id + ) + ).first() - if not reset_token: + if not result: return None, None - user = session.exec(select(User).where( - User.email == email, - User.id == reset_token.user_id - )).first() - + user, reset_token = result return user, reset_token From bf63caf9f96a9af17d28996c97204ec203b252b1 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:58:39 +0000 Subject: [PATCH 24/73] New authentication error handler in main.py --- main.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 676808e..5642f6a 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException from sqlmodel import Session from routers import authentication, organization, role, user -from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError +from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError from utils.models import User from utils.db import get_session, set_up_db @@ -37,6 +37,15 @@ async def lifespan(app: FastAPI): # -- Exception Handling Middlewares -- +# Handle AuthenticationError by redirecting to login page +@app.exception_handler(AuthenticationError) +async def authentication_error_handler(request: Request, exc: AuthenticationError): + return RedirectResponse( + url="/login", + status_code=status.HTTP_303_SEE_OTHER + ) + + # Handle NeedsNewTokens by setting new tokens and redirecting to same page @app.exception_handler(NeedsNewTokens) async def needs_new_tokens_handler(request: Request, exc: NeedsNewTokens): @@ -104,10 +113,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE # Handle StarletteHTTPException (including 404, 405, etc.) by rendering the error page @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request: Request, exc: StarletteHTTPException): - # Don't handle redirects - if exc.status_code in [301, 302, 303, 307, 308]: - raise exc - return templates.TemplateResponse( request, "errors/error.html", From 2fe1570d2abd7ee8e8cc6bd4320a3482fc1af364 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:59:04 +0000 Subject: [PATCH 25/73] Fix misnamed endpoint in profile.html template --- templates/users/profile.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/users/profile.html b/templates/users/profile.html index 5afa794..448c0d6 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -34,7 +34,7 @@

User Profile

Edit Profile
- +
From a6fd524825cfe09db5e4a50d4223b197160ca328 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 00:59:37 +0000 Subject: [PATCH 26/73] New test fixtures for authed and unauthed clients, tests for user endpoints --- tests/conftest.py | 56 +++++++++++++++++-------- tests/test_authentication.py | 75 ++++++++++++++++----------------- tests/test_user.py | 81 +++++++++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 57 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index da1eec2..f9b90ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from utils.db import get_connection_url, set_up_db, tear_down_db, get_session from utils.models import User, PasswordResetToken -from utils.auth import get_password_hash +from utils.auth import get_password_hash, create_access_token, create_refresh_token from main import app load_dotenv() @@ -54,22 +54,6 @@ def clean_db(session: Session): session.commit() -# Test client fixture -@pytest.fixture() -def client(session: Session): - """ - Provides a TestClient instance with the session fixture. - Overrides the get_session dependency to use the test session. - """ - def get_session_override(): - return session - - app.dependency_overrides[get_session] = get_session_override - client = TestClient(app) - yield client - app.dependency_overrides.clear() - - # Test user fixture @pytest.fixture() def test_user(session: Session): @@ -85,3 +69,41 @@ def test_user(session: Session): session.commit() session.refresh(user) return user + + +# Unauthenticated client fixture +@pytest.fixture() +def unauth_client(session: Session): + """ + Provides a TestClient instance without authentication. + """ + def get_session_override(): + return session + + app.dependency_overrides[get_session] = get_session_override + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +# Authenticated client fixture +@pytest.fixture() +def auth_client(session: Session, test_user: User): + """ + Provides a TestClient instance with valid authentication tokens. + """ + def get_session_override(): + return session + + app.dependency_overrides[get_session] = get_session_override + client = TestClient(app) + + # Create and set valid tokens + access_token = create_access_token({"sub": test_user.email}) + refresh_token = create_refresh_token({"sub": test_user.email}) + + client.cookies.set("access_token", access_token) + client.cookies.set("refresh_token", refresh_token) + + yield client + app.dependency_overrides.clear() diff --git a/tests/test_authentication.py b/tests/test_authentication.py index ac0df9e..0ba2331 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -86,9 +86,9 @@ def test_invalid_token_type(): # --- API Endpoint Tests --- -def test_register_endpoint(client: TestClient, session: Session): - response = client.post( - "/auth/register", +def test_register_endpoint(unauth_client: TestClient, session: Session): + response = unauth_client.post( + app.url_path_for("register"), data={ "name": "New User", "email": "new@example.com", @@ -107,9 +107,9 @@ def test_register_endpoint(client: TestClient, session: Session): assert verify_password("NewPass123!@#", user.hashed_password) -def test_login_endpoint(client: TestClient, test_user: User): - response = client.post( - "/auth/login", +def test_login_endpoint(unauth_client: TestClient, test_user: User): + response = unauth_client.post( + app.url_path_for("login"), data={ "email": test_user.email, "password": "Test123!@#" @@ -124,18 +124,18 @@ def test_login_endpoint(client: TestClient, test_user: User): assert "refresh_token" in cookies -def test_refresh_token_endpoint(client: TestClient, test_user: User): - # Create expired access token and valid refresh token - access_token = create_access_token( +def test_refresh_token_endpoint(auth_client: TestClient, test_user: User): + # Override just the access token to be expired, keeping the valid refresh token + expired_access_token = create_access_token( {"sub": test_user.email}, timedelta(minutes=-10) ) - refresh_token = create_refresh_token({"sub": test_user.email}) + auth_client.cookies.set("access_token", expired_access_token) - client.cookies.set("access_token", access_token) - client.cookies.set("refresh_token", refresh_token) - - response = client.post("/auth/refresh", follow_redirects=False) + response = auth_client.post( + app.url_path_for("refresh_token"), + follow_redirects=False + ) assert response.status_code == 303 # Check for new tokens in headers @@ -155,10 +155,10 @@ def test_refresh_token_endpoint(client: TestClient, test_user: User): assert decoded["sub"] == test_user.email -def test_password_reset_flow(client: TestClient, session: Session, test_user: User, mock_resend_send): +def test_password_reset_flow(unauth_client: TestClient, session: Session, test_user: User, mock_resend_send): # Test forgot password request - response = client.post( - "/auth/forgot_password", + response = unauth_client.post( + app.url_path_for("forgot_password"), data={"email": test_user.email}, follow_redirects=False ) @@ -188,8 +188,8 @@ def test_password_reset_flow(client: TestClient, session: Session, test_user: Us assert not reset_token.used # Test password reset - response = client.post( - "/auth/reset_password", + response = unauth_client.post( + app.url_path_for("reset_password"), data={ "email": test_user.email, "token": reset_token.token, @@ -207,12 +207,11 @@ def test_password_reset_flow(client: TestClient, session: Session, test_user: Us assert reset_token.used -def test_logout_endpoint(client: TestClient): - # First set some cookies - client.cookies.set("access_token", "some_access_token") - client.cookies.set("refresh_token", "some_refresh_token") - - response = client.get("/auth/logout", follow_redirects=False) +def test_logout_endpoint(auth_client: TestClient): + response = auth_client.get( + app.url_path_for("logout"), + follow_redirects=False + ) assert response.status_code == 303 # Check for cookie deletion in headers @@ -226,9 +225,9 @@ def test_logout_endpoint(client: TestClient): # --- Error Case Tests --- -def test_register_with_existing_email(client: TestClient, test_user: User): - response = client.post( - "/auth/register", +def test_register_with_existing_email(unauth_client: TestClient, test_user: User): + response = unauth_client.post( + app.url_path_for("register"), data={ "name": "Another User", "email": test_user.email, @@ -239,9 +238,9 @@ def test_register_with_existing_email(client: TestClient, test_user: User): assert response.status_code == 400 -def test_login_with_invalid_credentials(client: TestClient, test_user: User): - response = client.post( - "/auth/login", +def test_login_with_invalid_credentials(unauth_client: TestClient, test_user: User): + response = unauth_client.post( + app.url_path_for("login"), data={ "email": test_user.email, "password": "WrongPass123!@#" @@ -250,9 +249,9 @@ def test_login_with_invalid_credentials(client: TestClient, test_user: User): assert response.status_code == 400 -def test_password_reset_with_invalid_token(client: TestClient, test_user: User): - response = client.post( - "/auth/reset_password", +def test_password_reset_with_invalid_token(unauth_client: TestClient, test_user: User): + response = unauth_client.post( + app.url_path_for("reset_password"), data={ "email": test_user.email, "token": "invalid_token", @@ -263,7 +262,7 @@ def test_password_reset_with_invalid_token(client: TestClient, test_user: User): assert response.status_code == 400 -def test_password_reset_url_generation(client: TestClient): +def test_password_reset_url_generation(unauth_client: TestClient): """ Tests that the password reset URL is correctly formatted and contains the required query parameters. @@ -290,12 +289,12 @@ def test_password_reset_url_generation(client: TestClient): assert query_params["token"][0] == test_token -def test_password_reset_email_url(client: TestClient, session: Session, test_user: User, mock_resend_send): +def test_password_reset_email_url(unauth_client: TestClient, session: Session, test_user: User, mock_resend_send): """ Tests that the password reset email contains a properly formatted reset URL. """ - response = client.post( - "/auth/forgot_password", + response = unauth_client.post( + app.url_path_for("forgot_password"), data={"email": test_user.email}, follow_redirects=False ) diff --git a/tests/test_user.py b/tests/test_user.py index 26c4dba..294b9eb 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,6 +1,83 @@ -import pytest from fastapi.testclient import TestClient -from sqlmodel import Session, select +from fastapi.responses import Response +from sqlmodel import Session from main import app from utils.models import User + + +def test_update_profile_unauthorized(unauth_client: TestClient): + """Test that unauthorized users cannot edit profile""" + response: Response = unauth_client.post( + app.url_path_for("update_profile"), + data={ + "name": "New Name", + "email": "new@example.com", + "avatar_url": "https://example.com/avatar.jpg" + }, + follow_redirects=False + ) + assert response.status_code == 303 # Redirect to login + assert response.headers["location"] == app.url_path_for("read_login") + + +def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session): + """Test that authorized users can edit their profile""" + + # Update profile + response: Response = auth_client.post( + app.url_path_for("update_profile"), + data={ + "name": "Updated Name", + "email": "updated@example.com", + "avatar_url": "https://example.com/new-avatar.jpg" + }, + follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == app.url_path_for("read_profile") + + # Verify changes in database + session.refresh(test_user) + assert test_user.name == "Updated Name" + assert test_user.email == "updated@example.com" + assert test_user.avatar_url == "https://example.com/new-avatar.jpg" + + +def test_delete_account_unauthorized(unauth_client: TestClient): + """Test that unauthorized users cannot delete account""" + response: Response = unauth_client.post( + app.url_path_for("delete_account"), + data={"confirm_delete_password": "Test123!@#"}, + follow_redirects=False + ) + assert response.status_code == 303 # Redirect to login + assert response.headers["location"] == app.url_path_for("read_login") + + +def test_delete_account_wrong_password(auth_client: TestClient, test_user: User): + """Test that account deletion fails with wrong password""" + response: Response = auth_client.post( + app.url_path_for("delete_account"), + data={"confirm_delete_password": "WrongPassword123!"}, + follow_redirects=False + ) + assert response.status_code == 400 + assert "Password is incorrect" in response.text + + +def test_delete_account_success(auth_client: TestClient, test_user: User, session: Session): + """Test successful account deletion""" + + # Delete account + response: Response = auth_client.post( + app.url_path_for("delete_account"), + data={"confirm_delete_password": "Test123!@#"}, + follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == app.url_path_for("logout") + + # Verify user is deleted from database + user = session.get(User, test_user.id) + assert user is None From 26073b460a239a8607d454f920fe996e52836d22 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 01:12:02 +0000 Subject: [PATCH 27/73] Fixed a type lint error and updated pytest docs --- docs/customization.qmd | 3 ++- tests/test_user.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/customization.qmd b/docs/customization.qmd index 279bbaf..00e9685 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -25,7 +25,8 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes - `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. - `session`: Provides a session for database operations in tests. - `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. -- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session. +- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture. +- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture. - `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. To run the tests, use these commands: diff --git a/tests/test_user.py b/tests/test_user.py index 294b9eb..d6f42d0 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,5 +1,5 @@ from fastapi.testclient import TestClient -from fastapi.responses import Response +from httpx import Response from sqlmodel import Session from main import app @@ -63,7 +63,7 @@ def test_delete_account_wrong_password(auth_client: TestClient, test_user: User) follow_redirects=False ) assert response.status_code == 400 - assert "Password is incorrect" in response.text + assert "Password is incorrect" in response.text.strip() def test_delete_account_success(auth_client: TestClient, test_user: User, session: Session): From e1ee3d75ab23d7d05408830667a1f9f826ab1f0f Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 01:34:04 +0000 Subject: [PATCH 28/73] Adjusted password strength regex to allow forward slashes --- templates/authentication/register.html | 2 +- utils/auth.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/authentication/register.html b/templates/authentication/register.html index 69320a1..ceb8aac 100644 --- a/templates/authentication/register.html +++ b/templates/authentication/register.html @@ -25,7 +25,7 @@
diff --git a/utils/auth.py b/utils/auth.py index 8fbae0b..5793c3e 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -12,7 +12,6 @@ from datetime import UTC, datetime, timedelta from typing import Optional from fastapi import Depends, Cookie, HTTPException, status -from fastapi.responses import RedirectResponse from utils.db import get_session from utils.models import User, PasswordResetToken @@ -76,7 +75,7 @@ def validate_password_strength(v: str) -> str: """ logger.debug(f"Validating password for {field_name}") pattern = re.compile( - r"(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&{}<>.,\\'#\-_=+\(\)\[\]:;|~])[A-Za-z\d@$!%*?&{}<>.,\\'#\-_=+\(\)\[\]:;|~]{8,}") + r"(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&{}<>.,\\'#\-_=+\(\)\[\]:;|~/])[A-Za-z\d@$!%*?&{}<>.,\\'#\-_=+\(\)\[\]:;|~/]{8,}") if not pattern.match(v): logger.debug(f"Password for { field_name} does not satisfy the security policy") From b165e0a2ede652c8df7c7e2ccd5327e723747afe Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 25 Nov 2024 23:14:11 +0000 Subject: [PATCH 29/73] Document that we need to use -v flag when running docker compose down --- docs/installation.qmd | 21 ++++++++++++++++++++- index.qmd | 2 ++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/installation.qmd b/docs/installation.qmd index 7167589..2014027 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -20,6 +20,8 @@ If you use VSCode with Docker to develop in a container, the following VSCode De Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. +*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* + ## Install development dependencies manually ### Python and Docker @@ -103,15 +105,32 @@ Set your desired database name, username, and password in the .env file. To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. +If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) + ## Start development database +To start the development database, run the following command in your terminal from the root directory: + ``` bash docker compose up -d ``` +If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*: + +``` bash +# Don't forget the -v flag to tear down the volume! +docker compose down -v +``` + +You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt: + +``` bash +docker compose up -d --force-recreate --build +``` + ## Run the development server -Make sure the development database is running and tables and default permissions/roles are created first. +Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory: ``` bash uvicorn main:app --host 0.0.0.0 --port 8000 --reload diff --git a/index.qmd b/index.qmd index 0266e7b..b23f439 100644 --- a/index.qmd +++ b/index.qmd @@ -107,6 +107,8 @@ To use password recovery, register a [Resend](https://resend.com/) account, veri ### Start development database +To start the development database, run the following command in your terminal from the root directory: + ``` bash docker compose up -d ``` From ac253781eb47a7544960ed0fd7bac8a2103433d7 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Tue, 26 Nov 2024 15:31:39 +0000 Subject: [PATCH 30/73] Added llms.txt generation script in index.qmd --- docs/customization.qmd | 4 + docs/static/llms.txt | 992 +++++++++++++++++++++++++++++++++++++++++ index.qmd | 71 ++- 3 files changed, 1065 insertions(+), 2 deletions(-) create mode 100644 docs/static/llms.txt diff --git a/docs/customization.qmd b/docs/customization.qmd index d032cac..d2b0504 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -45,6 +45,10 @@ mypy We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change! +### Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. + ## Project structure ### Customizable folders and files diff --git a/docs/static/llms.txt b/docs/static/llms.txt new file mode 100644 index 0000000..8f5edce --- /dev/null +++ b/docs/static/llms.txt @@ -0,0 +1,992 @@ +# FastAPI, Jinja2, PostgreSQL Webapp Template + +![Screenshot of homepage](docs/static/Screenshot.png) + +## Quickstart + +This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html). + +## Features + +This template combines three of the most lightweight and performant open-source web development frameworks into a customizable webapp template with: + +- Pure Python backend +- Minimal-Javascript frontend +- Powerful, easy-to-manage database + +The template also includes full-featured secure auth with: + +- Token-based authentication +- Password recovery flow +- Role-based access control system + +## Design Philosophy + +The design philosophy of the template is to prefer low-level, best-in-class open-source frameworks that offer flexibility, scalability, and performance without vendor-lock-in. You'll find the template amazingly easy not only to understand and customize, but also to deploy to any major cloud hosting platform. + +## Tech Stack + +**Core frameworks:** + +- [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework +- [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine +- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine +- [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM + +**Additional technologies:** + +- [Poetry](https://python-poetry.org/): Python dependency manager +- [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework +- [Docker](https://www.docker.com/): development containerization +- [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline +- [Quarto](https://quarto.org/docs/): simple documentation website renderer +- [MyPy](https://mypy.readthedocs.io/en/stable/): static type checker for Python +- [Bootstrap](https://getbootstrap.com/): HTML/CSS styler +- [Resend](https://resend.com/): zero- or low-cost email service used for password recovery + +## Installation + +For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). + +### Python and Docker + +- [Python 3.12 or higher](https://www.python.org/downloads/) +- [Docker and Docker Compose](https://docs.docker.com/get-docker/) + +### PostgreSQL headers + +For Ubuntu/Debian: + +``` bash +sudo apt update && sudo apt install -y python3-dev libpq-dev +``` + +For macOS: + +``` bash +brew install postgresql +``` + +For Windows: + +- No installation required + +### Python dependencies + +1. Install Poetry + +``` bash +pipx install poetry +``` + +2. Install project dependencies + +``` bash +poetry install +``` + +3. Activate shell + +``` bash +poetry shell +``` + +(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) + +### Set environment variables + +Copy .env.example to .env with `cp .env.example .env`. + +Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. + +Set your desired database name, username, and password in the .env file. + +To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. + +### Start development database + +``` bash +docker compose up -d +``` + +### Run the development server + +Make sure the development database is running and tables and default permissions/roles are created first. + +``` bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +Navigate to http://localhost:8000/ + +### Lint types with mypy + +``` bash +mypy . +``` + +## Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. + +``` {python} +#| echo: false +#| include: false +import re +from pathlib import Path + + +def extract_file_paths(quarto_yml_path): + """ + Extract href paths from _quarto.yml file. + Returns a list of .qmd file paths. + """ + with open(quarto_yml_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all href entries that point to .qmd files + pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$' + matches = re.findall(pattern, content, re.MULTILINE) + return matches + + +def process_qmd_content(file_path): + """ + Process a .qmd file by converting YAML frontmatter to markdown heading. + Returns the processed content as a string. + """ + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Replace YAML frontmatter with markdown heading + pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---' + processed_content = re.sub(pattern, r'# \1', content) + return processed_content + + +# Get the current working directory +base_dir = Path.cwd() +quarto_yml_path = base_dir / '_quarto.yml' + +# Extract file paths from _quarto.yml +qmd_files = extract_file_paths(quarto_yml_path) + +# Process each .qmd file and collect contents +processed_contents = [] +for qmd_file in qmd_files: + file_path = base_dir / qmd_file + if file_path.exists(): + processed_content = process_qmd_content(file_path) + processed_contents.append(processed_content) + +# Concatenate all contents with double newline separator +final_content = '\n\n'.join(processed_contents) + +# Ensure the output directory exists +output_dir = base_dir / 'docs' / 'static' +output_dir.mkdir(parents=True, exist_ok=True) + +# Write the concatenated content to the output file +output_path = output_dir / 'llms.txt' +with open(output_path, 'w', encoding='utf-8') as f: + f.write(final_content) +``` + +## Contributing + +Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. + +## License + +This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT License. See the LICENSE file for more details. + + +# Architecture + +## Data flow + +This application uses a Post-Redirect-Get (PRG) pattern. The user submits a form, which sends a POST request to a FastAPI endpoint on the server. The database is updated, and the user is redirected to a GET endpoint, which fetches the updated data and re-renders the Jinja2 page template with the new data. + +``` {python} +#| echo: false +#| include: false +from graphviz import Digraph + +dot = Digraph() +dot.attr(rankdir='TB') +dot.attr('node', shape='box', style='rounded') + +# Create client subgraph at top +with dot.subgraph(name='cluster_client') as client: + client.attr(label='Client') + client.attr(rank='topmost') + client.node('A', 'User submits form', fillcolor='lightblue', style='rounded,filled') + client.node('B', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled') + +# Create server subgraph below +with dot.subgraph(name='cluster_server') as server: + server.attr(label='Server') + server.node('C', 'Convert to Pydantic model', fillcolor='lightgreen', style='rounded,filled') + server.node('D', 'Optional custom validation', fillcolor='lightgreen', style='rounded,filled') + server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled') + server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled') + server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled') + server.node('H', 'Redirect to GET endpoint', fillcolor='lightgreen', style='rounded,filled') + server.node('I', 'Fetch updated data', fillcolor='lightgreen', style='rounded,filled') + server.node('K', 'Re-render Jinja2 page template', fillcolor='lightgreen', style='rounded,filled') + +with dot.subgraph(name='cluster_client_post') as client_post: + client_post.attr(label='Client') + client_post.attr(rank='bottommost') + client_post.node('J', 'Display rendered page', fillcolor='lightblue', style='rounded,filled') + +# Add visible edges +dot.edge('A', 'B') +dot.edge('B', 'A') +dot.edge('B', 'C', label='POST Request to FastAPI endpoint') +dot.edge('C', 'D') +dot.edge('C', 'F', label='RequestValidationError') +dot.edge('D', 'E', label='Valid data') +dot.edge('D', 'F', label='Custom Validation Error') +dot.edge('E', 'H', label='Data updated') +dot.edge('H', 'I') +dot.edge('I', 'K') +dot.edge('K', 'J', label='Return HTML') +dot.edge('F', 'G') +dot.edge('G', 'J', label='Return HTML') + +dot.render('static/data_flow', format='png', cleanup=True) +``` + +![Data flow diagram](static/data_flow.png) + +The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. + +## Form validation flow + +We've experimented with several approaches to validating form inputs in the FastAPI endpoints. + +### Objectives + +Ideally, on an invalid input, we would redirect the user back to the form, preserving their inputs and displaying an error message about which input was invalid. + +This would keep the error handling consistent with the PRG pattern described in the [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture) section of this documentation. + +To keep the code DRY, we'd also like to handle such validation with Pydantic dependencies, Python exceptions, and exception-handling middleware as much as possible. + +### Obstacles + +One challenge is that if we redirect back to the page with the form, the page is re-rendered with empty form fields. + +This can be overcome by passing the inputs from the request as context variables to the template. + +But that's a bit clunky, because then we have to support form-specific context variables in every form page and corresponding GET endpoint. + +Also, we have to: + +1. access the request object (which is not by default available to our middleware), and +2. extract the form inputs (at least one of which is invalid in this error case), and +3. pass the form inputs to the template (which is a bit challenging to do in a DRY way since there are different sets of form inputs for different forms). + +Solving these challenges is possible, but gets high-complexity pretty quickly. + +### Approaches + +The best solution, I think, is to use really robust client-side form validation to prevent invalid inputs from being sent to the server in the first place. That makes it less important what we do on the server side, although we still need to handle the server-side error case as a backup in the event that something slips past our validation on the client side. + +Here are some patterns we've considered for server-side error handling: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDApproachReturns to same pagePreserves form inputsFollows PRG patternComplexity
1Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" buttonNoYesYesLow
2Validate in FastAPI endpoint function body, redirect to origin page with error message query paramYesNoYesMedium
3Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
4Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
5Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level JavascriptYesYesNoLow
+ +Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. + +# Authentication + +## Security features + +This template implements a comprehensive authentication system with security best practices: + +1. **Token Security**: + - JWT-based with separate access/refresh tokens + - Strict expiry times (30 min access, 30 day refresh) + - Token type validation + - HTTP-only cookies + - Secure flag enabled + - SameSite=strict restriction + +2. **Password Security**: + - Strong password requirements enforced + - Bcrypt hashing with random salt + - Password reset tokens are single-use + - Reset tokens have expiration + +3. **Cookie Security**: + - HTTP-only prevents JavaScript access + - Secure flag ensures HTTPS only + - Strict SameSite prevents CSRF + +4. **Error Handling**: + - Validation errors properly handled + - Security-related errors don't leak information + - Comprehensive error logging + +The diagrams below show the main authentication flows. + +## Registration and login flow + +``` {python} +#| echo: false +#| include: false +from graphviz import Digraph + +# Create graph for registration/login +auth = Digraph(name='auth_flow') +auth.attr(rankdir='TB') +auth.attr('node', shape='box', style='rounded') + +# Client-side nodes +with auth.subgraph(name='cluster_client') as client: + client.attr(label='Client') + client.node('register_form', 'Submit registration', fillcolor='lightblue', style='rounded,filled') + client.node('login_form', 'Submit login', fillcolor='lightblue', style='rounded,filled') + client.node('store_cookies', 'Store secure cookies', fillcolor='lightblue', style='rounded,filled') + +# Server-side nodes +with auth.subgraph(name='cluster_server') as server: + server.attr(label='Server') + # Registration path + server.node('validate_register', 'Validate registration data', fillcolor='lightgreen', style='rounded,filled') + server.node('hash_new', 'Hash new password', fillcolor='lightgreen', style='rounded,filled') + server.node('store_user', 'Store user in database', fillcolor='lightgreen', style='rounded,filled') + + # Login path + server.node('validate_login', 'Validate login data', fillcolor='lightgreen', style='rounded,filled') + server.node('verify_password', 'Verify password hash', fillcolor='lightgreen', style='rounded,filled') + server.node('fetch_user', 'Fetch user from database', fillcolor='lightgreen', style='rounded,filled') + + # Common path + server.node('generate_tokens', 'Generate JWT tokens', fillcolor='lightgreen', style='rounded,filled') + +# Registration path +auth.edge('register_form', 'validate_register', 'POST /register') +auth.edge('validate_register', 'hash_new') +auth.edge('hash_new', 'store_user') +auth.edge('store_user', 'generate_tokens', 'Success') + +# Login path +auth.edge('login_form', 'validate_login', 'POST /login') +auth.edge('validate_login', 'fetch_user') +auth.edge('fetch_user', 'verify_password') +auth.edge('verify_password', 'generate_tokens', 'Success') + +# Common path +auth.edge('generate_tokens', 'store_cookies', 'Set-Cookie') + +auth.render('static/auth_flow', format='png', cleanup=True) +``` + +![Registration and login flow](static/auth_flow.png) + +## Password reset flow + +``` {python} +#| echo: false +#| include: false +from graphviz import Digraph + +# Create graph for password reset +reset = Digraph(name='reset_flow') +reset.attr(rankdir='TB') +reset.attr('node', shape='box', style='rounded') + +# Client-side nodes - using light blue fill +reset.node('forgot', 'User submits forgot password form', fillcolor='lightblue', style='rounded,filled') +reset.node('reset', 'User submits reset password form', fillcolor='lightblue', style='rounded,filled') +reset.node('email_client', 'User clicks reset link', fillcolor='lightblue', style='rounded,filled') + +# Server-side nodes - using light green fill +reset.node('validate', 'Validation', fillcolor='lightgreen', style='rounded,filled') +reset.node('token_gen', 'Generate reset token', fillcolor='lightgreen', style='rounded,filled') +reset.node('hash', 'Hash password', fillcolor='lightgreen', style='rounded,filled') +reset.node('email_server', 'Send email with Resend', fillcolor='lightgreen', style='rounded,filled') +reset.node('db', 'Database', shape='cylinder', fillcolor='lightgreen', style='filled') + +# Add edges with labels +reset.edge('forgot', 'token_gen', 'POST') +reset.edge('token_gen', 'db', 'Store') +reset.edge('token_gen', 'email_server', 'Add email/token as URL parameter') +reset.edge('email_server', 'email_client') +reset.edge('email_client', 'reset', 'Set email/token as form input') +reset.edge('reset', 'validate', 'POST') +reset.edge('validate', 'hash') +reset.edge('hash', 'db', 'Update') + +reset.render('static/reset_flow', format='png', cleanup=True) +``` + +![Password reset flow](static/reset_flow.png) + + +# Installation + +## Install all dependencies in a VSCode Dev Container + +If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all dependencies: + +``` json +{ + "name": "Python 3", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && pipx install poetry && poetry install && poetry shell", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {} + } +} +``` + +Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. + +## Install development dependencies manually + +### Python and Docker + +- [Python 3.12 or higher](https://www.python.org/downloads/) +- [Docker and Docker Compose](https://docs.docker.com/get-docker/) + +### PostgreSQL headers + +For Ubuntu/Debian: + +``` bash +sudo apt update && sudo apt install -y python3-dev libpq-dev +``` + +For macOS: + +``` bash +brew install postgresql +``` + +For Windows: + +- No installation required + +### Python dependencies + +1. Install Poetry + +``` bash +pipx install poetry +``` + +2. Install project dependencies + +``` bash +poetry install +``` + +3. Activate shell + +``` bash +poetry shell +``` + +(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) + +## Install documentation dependencies manually + +### Quarto CLI + +To render the project documentation, you will need to download and install the [Quarto CLI](https://quarto.org/docs/get-started/) for your operating system. + +### Graphviz + +Architecture diagrams in the documentation are rendered with [Graphviz](https://graphviz.org/). + +For macOS: + +``` bash +brew install graphviz +``` + +For Ubuntu/Debian: + +``` bash +sudo apt update && sudo apt install -y graphviz +``` + +For Windows: + +- Download and install from [Graphviz.org](https://graphviz.org/download/#windows) + +## Set environment variables + +Copy .env.example to .env with `cp .env.example .env`. + +Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. + +Set your desired database name, username, and password in the .env file. + +To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. + +## Start development database + +``` bash +docker compose up -d +``` + +## Run the development server + +Make sure the development database is running and tables and default permissions/roles are created first. + +``` bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +Navigate to http://localhost:8000/ + +## Lint types with mypy + +``` bash +mypy . +``` + + +# Customization + +## Development workflow + +### Dependency management with Poetry + +The project uses Poetry to manage dependencies: + +- Add new dependency: `poetry add ` +- Add development dependency: `poetry add --dev ` +- Remove dependency: `poetry remove ` +- Update lock file: `poetry lock` +- Install dependencies: `poetry install` +- Update all dependencies: `poetry update` + +### Testing + +The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! + +The following fixtures, defined in `tests/conftest.py`, are available in the test suite: + +- `engine`: Creates a new SQLModel engine for the test database. +- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. +- `session`: Provides a session for database operations in tests. +- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. +- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session. +- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. + +To run the tests, use these commands: + +- Run all tests: `pytest` +- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` +- Run particular test files by name: `pytest ` +- Run particular tests by name: `pytest -k ` + +### Type checking with mypy + +The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: + +```bash +mypy +``` + +We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change! + +### Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. + +## Project structure + +### Customizable folders and files + +- FastAPI application entry point and GET routes: `main.py` +- FastAPI POST routes: `routers/` + - User authentication endpoints: `auth.py` + - User profile management endpoints: `user.py` + - Organization management endpoints: `organization.py` + - Role management endpoints: `role.py` +- Jinja2 templates: `templates/` +- Static assets: `static/` +- Unit tests: `tests/` +- Test database configuration: `docker-compose.yml` +- Helper functions: `utils/` + - Auth helpers: `auth.py` + - Database helpers: `db.py` + - Database models: `models.py` +- Environment variables: `.env` +- CI/CD configuration: `.github/` +- Project configuration: `pyproject.toml` +- Quarto documentation: + - Source: `index.qmd` + `docs/` + - Configuration: `_quarto.yml` + +Most everything else is auto-generated and should not be manually modified. + +### Defining a web backend with FastAPI + +We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. + +We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) + +#### Routing patterns in this template + +In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. + +We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: + +```python +# -- Authenticated Routes -- +``` + +Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. + +### HTML templating with Jinja2 + +To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. + +With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. + +#### Context variables + +Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: + +```python +@app.get("/welcome") +async def welcome(request: Request): + return templates.TemplateResponse( + "welcome.html", + {"username": "Alice"} + ) +``` + +In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. + +#### Form validation strategy + +While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. + +Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: +- Provide immediate feedback to users +- Reduce server load +- Improve user experience by avoiding round-trips to the server +- Prevent malformed data from ever reaching the backend + +Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. + +### Writing type annotated code + +Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. + +If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. + +For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. + +#### Defining request models and custom validators + +For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: + +```python +class PasswordMismatchError(HTTPException): + def __init__(self, field: str = "confirm_password"): + super().__init__( + status_code=422, + detail={ + "field": field, + "message": "The passwords you entered do not match" + } + ) + +class UserRegister(BaseModel): + name: str + email: EmailStr + password: str + confirm_password: str + + # Custom validators are added as class attributes + @field_validator("confirm_password", check_fields=False) + def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: + if v != values["password"]: + raise PasswordMismatchError() + return v + # ... +``` + +We then add this request model as a dependency in the signature of our POST route: + +```python +@app.post("/register") +async def register(request: UserRegister = Depends()): + # ... +``` + +When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. + +(Note that these examples are simplified versions of the actual code.) + +#### Converting form data to request models + +In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: + +```python +class UserRegister(BaseModel): + # ... + + @classmethod + async def as_form( + cls, + name: str = Form(...), + email: EmailStr = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) + ): + return cls( + name=name, + email=email, + password=password, + confirm_password=confirm_password + ) +``` + +#### Middleware exception handling + +Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. + +This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses. + +Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific. + +Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: + +```python +@app.exception_handler(PasswordMismatchError) +async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): + return templates.TemplateResponse( + request, + "errors/validation_error.html", + { + "status_code": 422, + "errors": {"error": exc.detail} + }, + status_code=422, + ) +``` + +### Database configuration and access with SQLModel + +SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. + +#### Models and relationships + +Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: + +- `Organization`: Represents a company or team +- `User`: Represents a user account +- `Role`: Represents a discrete set of user permissions within an organization +- `Permission`: Represents specific actions a user can perform +- `RolePermissionLink`: Maps roles to their allowed permissions +- `PasswordResetToken`: Manages password reset functionality + +Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: + +```{python} +#| echo: false +#| warning: false +import sys +sys.path.append("..") +from utils.models import * +from utils.db import engine +from sqlalchemy import MetaData +from sqlalchemy_schemadisplay import create_schema_graph + +# Create the directed graph +graph = create_schema_graph( + engine=engine, + metadata=SQLModel.metadata, + show_datatypes=True, + show_indexes=True, + rankdir='TB', + concentrate=False +) + +# Save the graph +graph.write_png('static/schema.png') +``` + +![Database Schema](static/schema.png) + + +#### Database operations + +Database operations are handled by helper functions in `utils/db.py`. Key functions include: + +- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) +- `get_connection_url()`: Creates a database connection URL from environment variables in `.env` +- `get_session()`: Provides a database session for performing operations + +To perform database operations in route handlers, inject the database session as a dependency: + +```python +@app.get("/users") +async def get_users(session: Session = Depends(get_session)): + users = session.exec(select(User)).all() + return users +``` + +The session automatically handles transaction management, ensuring that database operations are atomic and consistent. + + +# Deployment + +## Under construction + +# Contributing + +## Contributors + +### Opening issues and bug reports + +When opening a new issue or submitting a bug report, please include: + +1. A clear, descriptive title +2. For bug reports: + - Description of the expected behavior + - Description of the actual behavior + - Steps to reproduce the issue + - Version information (OS, Python version, package version) + - Any relevant error messages or screenshots +3. For feature requests: + - Description of the proposed feature + - Use case or motivation for the feature + - Any implementation suggestions (optional) + +Labels help categorize issues: +- Use `bug` for reporting problems +- Use `enhancement` for feature requests +- Use `documentation` for documentation improvements +- Use `question` for general queries + +### Contributing code + +To contribute code to the project: + +1. Fork the repository and clone your fork locally +2. Create a new branch from `main` with a descriptive name +3. Review the [customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/customization.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/architecture.html), and [authentication](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/authentication.html) pages for guidance on design patterns and code structure and style +4. Ensure all tests pass, including `mypy` type checking +5. Stage, commit, and push your changes to the branch: + - Use clear, descriptive commit messages + - Keep commits focused and atomic +6. Submit your pull request: + - Provide a clear description of the changes + - Link to any related issues + +### Rendering the documentation + +The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you ,make changes to the `.qmd` files in the root folder and the `docs` folder, run the following commands to re-render the docs: + +``` bash +# To render the documentation website +quarto render +# To render the README +quarto render index.qmd --output-dir . --output README.md --to gfm +``` + +Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. + +Note that even if your pull request is merged, your changes will not be reflected on the live website until a maintainer republishes the docs. + +## Maintainers + +### Git flow + +When creating new features, + +1. Open a Github issue with the label `feature` and assign it to yourself. +2. Create a new branch from the issue sidebar. +3. Follow the instructions in the popup to check out the branch locally and make your changes on the branch. +4. Commit your changes and push to the branch. +5. When you are ready to merge, open a pull request from the branch to main. +6. Assign someone else for code review. + +### Publishing the documentation + +To publish the documentation to GitHub Pages, run the following command: + +``` bash +quarto publish gh-pages +``` diff --git a/index.qmd b/index.qmd index 0266e7b..efed0b4 100644 --- a/index.qmd +++ b/index.qmd @@ -127,10 +127,77 @@ Navigate to http://localhost:8000/ mypy . ``` -### Contributing +## Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. + +``` {python} +#| echo: false +#| include: false +import re +from pathlib import Path + + +def extract_file_paths(quarto_yml_path): + """ + Extract href paths from _quarto.yml file. + Returns a list of .qmd file paths. + """ + with open(quarto_yml_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all href entries that point to .qmd files + pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$' + matches = re.findall(pattern, content, re.MULTILINE) + return matches + + +def process_qmd_content(file_path): + """ + Process a .qmd file by converting YAML frontmatter to markdown heading. + Returns the processed content as a string. + """ + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Replace YAML frontmatter with markdown heading + pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---' + processed_content = re.sub(pattern, r'# \1', content) + return processed_content + + +# Get the current working directory +base_dir = Path.cwd() +quarto_yml_path = base_dir / '_quarto.yml' + +# Extract file paths from _quarto.yml +qmd_files = extract_file_paths(quarto_yml_path) + +# Process each .qmd file and collect contents +processed_contents = [] +for qmd_file in qmd_files: + file_path = base_dir / qmd_file + if file_path.exists(): + processed_content = process_qmd_content(file_path) + processed_contents.append(processed_content) + +# Concatenate all contents with double newline separator +final_content = '\n\n'.join(processed_contents) + +# Ensure the output directory exists +output_dir = base_dir / 'docs' / 'static' +output_dir.mkdir(parents=True, exist_ok=True) + +# Write the concatenated content to the output file +output_path = output_dir / 'llms.txt' +with open(output_path, 'w', encoding='utf-8') as f: + f.write(final_content) +``` + +## Contributing Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. -### License +## License This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT License. See the LICENSE file for more details. From 476f69c885ed4af59ca2f4279e05b82d9b5c437c Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Wed, 27 Nov 2024 23:19:10 +0000 Subject: [PATCH 31/73] Reorganize database schema to allow 3-way user-org-role relationship --- .gitignore | 3 +- routers/organization.py | 86 +++++++++++++++++++++++++++++++---------- utils/models.py | 79 +++++++++++++++++++++---------------- 3 files changed, 113 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index b155443..3335ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__ /.quarto/ _docs/ .pytest_cache/ -.mypy_cache/ \ No newline at end of file +.mypy_cache/ +.cursorrules \ No newline at end of file diff --git a/routers/organization.py b/routers/organization.py index 9d4f1de..45e64f5 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -1,20 +1,66 @@ from logging import getLogger from fastapi import APIRouter, Depends, HTTPException, Form from fastapi.responses import RedirectResponse -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session -from utils.models import Organization +from utils.auth import get_authenticated_user +from utils.models import Organization, User from datetime import datetime logger = getLogger("uvicorn.error") +# -- Custom Exceptions -- + + +class EmptyOrganizationNameError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization name cannot be empty" + ) + + +class OrganizationExistsError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization already exists" + ) + + +class OrganizationNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=404, + detail="Organization not found" + ) + + +class OrganizationNameTakenError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization name already taken" + ) + + router = APIRouter(prefix="/organizations", tags=["organizations"]) +# -- Server Request and Response Models -- + + class OrganizationCreate(BaseModel): name: str + @field_validator('name') + @classmethod + def validate_name(cls, name: str) -> str: + if not name.strip(): + raise EmptyOrganizationNameError() + return name.strip() + @classmethod async def as_form(cls, name: str = Form(...)): return cls(name=name) @@ -34,26 +80,30 @@ class OrganizationUpdate(BaseModel): id: int name: str + @field_validator('name') + @classmethod + def validate_name(cls, name: str) -> str: + if not name.strip(): + raise EmptyOrganizationNameError() + return name.strip() + @classmethod async def as_form(cls, id: int = Form(...), name: str = Form(...)): return cls(id=id, name=name) +# -- Routes -- + @router.post("/", response_class=RedirectResponse) def create_organization( org: OrganizationCreate = Depends(OrganizationCreate.as_form), + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - # Validate organization name is not empty - if not org.name.strip(): - raise HTTPException( - status_code=400, detail="Organization name cannot be empty") - db_org = session.exec(select(Organization).where( Organization.name == org.name)).first() if db_org: - raise HTTPException( - status_code=400, detail="Organization already exists") + raise OrganizationExistsError() db_org = Organization(name=org.name) session.add(db_org) @@ -64,26 +114,22 @@ def create_organization( @router.get("/{org_id}", response_model=OrganizationRead) -def read_organization(org_id: int, session: Session = Depends(get_session)): +def read_organization(org_id: int, user: User = Depends(get_authenticated_user), session: Session = Depends(get_session)): db_org = session.get(Organization, org_id) if not db_org: - raise HTTPException(status_code=404, detail="Organization not found") + raise OrganizationNotFoundError() return db_org @router.put("/{org_id}", response_class=RedirectResponse) def update_organization( org: OrganizationUpdate = Depends(OrganizationUpdate.as_form), + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - # Validate organization name is not empty - if not org.name.strip(): - raise HTTPException( - status_code=400, detail="Organization name cannot be empty") - db_org = session.get(Organization, org.id) if not db_org: - raise HTTPException(status_code=404, detail="Organization not found") + raise OrganizationNotFoundError() # Check if new name already exists for another organization existing_org = session.exec( @@ -92,8 +138,7 @@ def update_organization( .where(Organization.id != org.id) ).first() if existing_org: - raise HTTPException( - status_code=400, detail="Organization name already taken") + raise OrganizationNameTakenError() db_org.name = org.name db_org.updated_at = datetime.utcnow() @@ -107,11 +152,12 @@ def update_organization( @router.delete("/{org_id}", response_class=RedirectResponse) def delete_organization( org_id: int, + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: db_org = session.get(Organization, org_id) if not db_org: - raise HTTPException(status_code=404, detail="Organization not found") + raise OrganizationNotFoundError() db_org.deleted = True db_org.updated_at = datetime.utcnow() diff --git a/utils/models.py b/utils/models.py index 5941f3f..11098cc 100644 --- a/utils/models.py +++ b/utils/models.py @@ -24,6 +24,27 @@ class ValidPermissions(Enum): EDIT_ROLE = "Edit Role" +class UserOrganizationLink(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + organization_id: int = Field(foreign_key="organization.id") + role_id: int = Field(foreign_key="role.id") + created_at: datetime = Field(default_factory=utc_time) + updated_at: datetime = Field(default_factory=utc_time) + + user: "User" = Relationship(back_populates="organization_links") + organization: "Organization" = Relationship(back_populates="user_links") + role: "Role" = Relationship(back_populates="user_links") + + +class RolePermissionLink(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + role_id: int = Field(foreign_key="role.id") + permission_id: int = Field(foreign_key="permission.id") + created_at: datetime = Field(default_factory=utc_time) + updated_at: datetime = Field(default_factory=utc_time) + + class Organization(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str @@ -31,21 +52,30 @@ class Organization(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_time) deleted: bool = Field(default=False) - users: List["User"] = Relationship(back_populates="organization") + user_links: List[UserOrganizationLink] = Relationship( + back_populates="organization") + users: List["User"] = Relationship( + back_populates="organizations", + link_model=UserOrganizationLink + ) + roles: List["Role"] = Relationship(back_populates="organization") class Role(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str - organization_id: Optional[int] = Field( - default=None, foreign_key="organization.id") + organization_id: int = Field(foreign_key="organization.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) deleted: bool = Field(default=False) - users: List["User"] = Relationship(back_populates="role") - role_permission_links: List["RolePermissionLink"] = Relationship( + organization: Organization = Relationship(back_populates="roles") + user_links: List[UserOrganizationLink] = Relationship( back_populates="role") + permissions: List["Permission"] = Relationship( + back_populates="roles", + link_model=RolePermissionLink + ) class Permission(SQLModel, table=True): @@ -56,21 +86,10 @@ class Permission(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_time) deleted: bool = Field(default=False) - role_permission_links: List["RolePermissionLink"] = Relationship( - back_populates="permission") - - -class RolePermissionLink(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - role_id: Optional[int] = Field( - default=None, foreign_key="role.id") - permission_id: Optional[int] = Field( - default=None, foreign_key="permission.id") - - role: Optional["Role"] = Relationship( - back_populates="role_permission_links") - permission: Optional["Permission"] = Relationship( - back_populates="role_permission_links") + roles: List["Role"] = Relationship( + back_populates="permissions", + link_model=RolePermissionLink + ) class PasswordResetToken(SQLModel, table=True): @@ -92,23 +111,15 @@ class User(SQLModel, table=True): email: str = Field(index=True, unique=True) hashed_password: str avatar_url: Optional[str] = None - organization_id: Optional[int] = Field( - default=None, foreign_key="organization.id") - role_id: Optional[int] = Field(default=None, foreign_key="role.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) deleted: bool = Field(default=False) - organization: Optional["Organization"] = Relationship( - back_populates="users") - role: Optional["Role"] = Relationship(back_populates="users") + organization_links: List[UserOrganizationLink] = Relationship( + back_populates="user") + organizations: List["Organization"] = Relationship( + back_populates="users", + link_model=UserOrganizationLink + ) password_reset_tokens: List["PasswordResetToken"] = Relationship( back_populates="user") - - -class UserOrganizationLink(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - user_id: Optional[int] = Field( - default=None, foreign_key="user.id") - organization_id: Optional[int] = Field( - default=None, foreign_key="organization.id") From 5225799b42885fae69f374f3ba15ad5ffe430d0f Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Wed, 27 Nov 2024 23:39:09 +0000 Subject: [PATCH 32/73] Perform org operations only if the user has permissions --- routers/organization.py | 105 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/routers/organization.py b/routers/organization.py index 45e64f5..7af287d 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -5,8 +5,9 @@ from sqlmodel import Session, select from utils.db import get_session from utils.auth import get_authenticated_user -from utils.models import Organization, User +from utils.models import Organization, User, Role, UserOrganizationLink, ValidPermissions, RolePermissionLink, Permission from datetime import datetime +from sqlalchemy import and_ logger = getLogger("uvicorn.error") @@ -45,6 +46,14 @@ def __init__(self): ) +class InsufficientPermissionsError(HTTPException): + def __init__(self): + super().__init__( + status_code=403, + detail="You don't have permission to perform this action" + ) + + router = APIRouter(prefix="/organizations", tags=["organizations"]) @@ -94,6 +103,49 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): # -- Routes -- +def check_user_permission( + session: Session, + user: User, + org_id: int, + permission: ValidPermissions +) -> bool: + """ + Check if user has the specified permission for the organization + """ + # Get user's role in the organization + user_org = session.exec( + select(UserOrganizationLink).where( + and_( + UserOrganizationLink.user_id == user.id, + UserOrganizationLink.organization_id == org_id + ) + ) + ).first() + + if not user_org: + return False + + # Get permission ID + permission_record = session.exec( + select(Permission).where(Permission.name == permission) + ).first() + + if not permission_record: + return False + + # Check if role has the permission + role_permission = session.exec( + select(RolePermissionLink).where( + and_( + RolePermissionLink.role_id == user_org.role_id, + RolePermissionLink.permission_id == permission_record.id + ) + ) + ).first() + + return bool(role_permission) + + @router.post("/", response_class=RedirectResponse) def create_organization( org: OrganizationCreate = Depends(OrganizationCreate.as_form), @@ -110,11 +162,54 @@ def create_organization( session.commit() session.refresh(db_org) + owner_role = session.exec( + select(Role).where( + and_( + Role.organization_id == db_org.id, + Role.name == "Owner" + ) + ) + ).first() + + if not owner_role: + owner_role = Role( + name="Owner", + organization_id=db_org.id + ) + session.add(owner_role) + session.commit() + session.refresh(owner_role) + + user_org_link = UserOrganizationLink( + user_id=user.id, + organization_id=db_org.id, + role_id=owner_role.id + ) + session.add(user_org_link) + session.commit() + return RedirectResponse(url=f"/organizations/{db_org.id}", status_code=303) @router.get("/{org_id}", response_model=OrganizationRead) -def read_organization(org_id: int, user: User = Depends(get_authenticated_user), session: Session = Depends(get_session)): +def read_organization( + org_id: int, + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +): + # First check if user is a member of the organization + user_org = session.exec( + select(UserOrganizationLink).where( + and_( + UserOrganizationLink.user_id == user.id, + UserOrganizationLink.organization_id == org_id + ) + ) + ).first() + + if not user_org: + raise InsufficientPermissionsError() + db_org = session.get(Organization, org_id) if not db_org: raise OrganizationNotFoundError() @@ -127,6 +222,9 @@ def update_organization( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: + if not check_user_permission(session, user, org.id, ValidPermissions.EDIT_ORGANIZATION): + raise InsufficientPermissionsError() + db_org = session.get(Organization, org.id) if not db_org: raise OrganizationNotFoundError() @@ -155,6 +253,9 @@ def delete_organization( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: + if not check_user_permission(session, user, org_id, ValidPermissions.DELETE_ORGANIZATION): + raise InsufficientPermissionsError() + db_org = session.get(Organization, org_id) if not db_org: raise OrganizationNotFoundError() From 11320b3b672f6d44141c044d99a2704a90491bda Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Wed, 27 Nov 2024 23:44:29 +0000 Subject: [PATCH 33/73] Moved role.py validation logic to request models and used custom HTTP exceptions --- routers/role.py | 86 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/routers/role.py b/routers/role.py index cb2488f..f879c63 100644 --- a/routers/role.py +++ b/routers/role.py @@ -1,9 +1,9 @@ from typing import List from datetime import datetime from logging import getLogger -from fastapi import APIRouter, Depends, HTTPException, Form +from fastapi import APIRouter, Depends, Form, HTTPException from fastapi.responses import RedirectResponse -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session from utils.models import Role, RolePermissionLink, ValidPermissions, utc_time @@ -13,15 +13,52 @@ router = APIRouter(prefix="/roles", tags=["roles"]) +# -- Custom Exceptions -- + +class RoleAlreadyExistsError(HTTPException): + """Raised when attempting to create a role with a name that already exists""" + + def __init__(self): + super().__init__(status_code=400, detail="Role already exists") + + +class RoleNotFoundError(HTTPException): + """Raised when a requested role does not exist or is deleted""" + + def __init__(self): + super().__init__(status_code=404, detail="Role not found") + + +# -- Server Request Models -- + class RoleCreate(BaseModel): model_config = ConfigDict(from_attributes=True) name: str permissions: List[ValidPermissions] + @field_validator("name") + @classmethod + def validate_unique_name(cls, name: str, info): + # Note: This requires passing session as a dependency to as_form + session = info.context.get("session") + if session and session.exec(select(Role).where(Role.name == name)).first(): + raise RoleAlreadyExistsError() + return name + @classmethod - async def as_form(cls, name: str = Form(...), permissions: List[ValidPermissions] = Form(...)): - return cls(name=name, permissions=permissions) + async def as_form( + cls, + name: str = Form(...), + permissions: List[ValidPermissions] = Form(...), + session: Session = Depends(get_session) + ): + # Pass session to validator context + return cls( + name=name, + permissions=permissions, + context={"session": session} + ) class RoleRead(BaseModel): @@ -42,20 +79,39 @@ class RoleUpdate(BaseModel): name: str permissions: List[ValidPermissions] + @field_validator("id") + @classmethod + def validate_role_exists(cls, id: int, info): + session = info.context.get("session") + if session: + role = session.get(Role, id) + if not role or not role.id or role.deleted: + raise RoleNotFoundError() + return id + @classmethod - async def as_form(cls, id: int = Form(...), name: str = Form(...), permissions: List[ValidPermissions] = Form(...)): - return cls(id=id, name=name, permissions=permissions) + async def as_form( + cls, + id: int = Form(...), + name: str = Form(...), + permissions: List[ValidPermissions] = Form(...), + session: Session = Depends(get_session) + ): + return cls( + id=id, + name=name, + permissions=permissions, + context={"session": session} + ) + +# -- Routes -- @router.post("/", response_class=RedirectResponse) def create_role( role: RoleCreate = Depends(RoleCreate.as_form), session: Session = Depends(get_session) ) -> RedirectResponse: - db_role = session.exec(select(Role).where(Role.name == role.name)).first() - if db_role: - raise HTTPException(status_code=400, detail="Role already exists") - # Create role and permissions in a single transaction db_role = Role(name=role.name) @@ -66,7 +122,7 @@ def create_role( ] session.add(db_role) - session.commit() # Commit once after all operations + session.commit() return RedirectResponse(url="/roles", status_code=303) @@ -75,7 +131,7 @@ def create_role( def read_role(role_id: int, session: Session = Depends(get_session)): db_role: Role | None = session.get(Role, role_id) if not db_role or not db_role.id or db_role.deleted: - raise HTTPException(status_code=404, detail="Role not found") + raise RoleNotFoundError() permissions = [ ValidPermissions(link.permission.name) @@ -99,8 +155,7 @@ def update_role( session: Session = Depends(get_session) ) -> RedirectResponse: db_role: Role | None = session.get(Role, role.id) - if not db_role or not db_role.id or db_role.deleted: - raise HTTPException(status_code=404, detail="Role not found") + role_data = role.model_dump(exclude_unset=True) for key, value in role_data.items(): setattr(db_role, key, value) @@ -130,7 +185,8 @@ def delete_role( ) -> RedirectResponse: db_role = session.get(Role, role_id) if not db_role: - raise HTTPException(status_code=404, detail="Role not found") + raise RoleNotFoundError() + db_role.deleted = True db_role.updated_at = utc_time() session.add(db_role) From 636bd45f9da6e85870d7ff9a135e23647b3871f5 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Wed, 27 Nov 2024 23:50:47 +0000 Subject: [PATCH 34/73] Added authenticated user dependencies --- routers/role.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/routers/role.py b/routers/role.py index f879c63..765331d 100644 --- a/routers/role.py +++ b/routers/role.py @@ -6,7 +6,8 @@ from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session -from utils.models import Role, RolePermissionLink, ValidPermissions, utc_time +from utils.auth import get_authenticated_user +from utils.models import Role, RolePermissionLink, ValidPermissions, utc_time, User logger = getLogger("uvicorn.error") @@ -15,6 +16,7 @@ # -- Custom Exceptions -- + class RoleAlreadyExistsError(HTTPException): """Raised when attempting to create a role with a name that already exists""" @@ -107,9 +109,11 @@ async def as_form( # -- Routes -- + @router.post("/", response_class=RedirectResponse) def create_role( role: RoleCreate = Depends(RoleCreate.as_form), + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: # Create role and permissions in a single transaction @@ -128,7 +132,11 @@ def create_role( @router.get("/{role_id}", response_model=RoleRead) -def read_role(role_id: int, session: Session = Depends(get_session)): +def read_role( + role_id: int, + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +): db_role: Role | None = session.get(Role, role_id) if not db_role or not db_role.id or db_role.deleted: raise RoleNotFoundError() @@ -152,6 +160,7 @@ def read_role(role_id: int, session: Session = Depends(get_session)): @router.put("/{role_id}", response_class=RedirectResponse) def update_role( role: RoleUpdate = Depends(RoleUpdate.as_form), + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: db_role: Role | None = session.get(Role, role.id) @@ -181,6 +190,7 @@ def update_role( @router.delete("/{role_id}", response_class=RedirectResponse) def delete_role( role_id: int, + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: db_role = session.get(Role, role_id) From a8bee0208ad69a4f9483753e8059015f52190c59 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Thu, 28 Nov 2024 14:47:35 +0000 Subject: [PATCH 35/73] Replaced role and organization endpoints with helper functions to return them as context --- routers/organization.py | 151 +++++++++++++++++++++++++++------------- routers/role.py | 56 ++++++++------- 2 files changed, 131 insertions(+), 76 deletions(-) diff --git a/routers/organization.py b/routers/organization.py index 7af287d..078fe17 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -5,9 +5,10 @@ from sqlmodel import Session, select from utils.db import get_session from utils.auth import get_authenticated_user -from utils.models import Organization, User, Role, UserOrganizationLink, ValidPermissions, RolePermissionLink, Permission +from utils.models import Organization, User, Role, UserOrganizationLink, ValidPermissions, RolePermissionLink, Permission, utc_time from datetime import datetime from sqlalchemy import and_ +from typing import List logger = getLogger("uvicorn.error") @@ -101,22 +102,99 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): return cls(id=id, name=name) -# -- Routes -- +# -- Helper Functions -- -def check_user_permission( +def get_user_organizations( + user_id: int, session: Session, - user: User, + include_deleted: bool = False +) -> List[Organization]: + """ + Retrieve all organizations a user is a member of. + + Args: + user_id: ID of the user + session: Database session + include_deleted: Whether to include soft-deleted organizations + + Returns: + List of Organization objects the user belongs to + """ + query = ( + select(Organization) + .join(UserOrganizationLink) + .where(UserOrganizationLink.user_id == user_id) + ) + + if not include_deleted: + query = query.where(Organization.deleted == False) + + return list(session.exec(query)) + + +def get_organization( org_id: int, - permission: ValidPermissions + user_id: int, + session: Session, +) -> Organization: + """ + Retrieve a specific organization if the user is a member. + + Args: + org_id: ID of the organization + user_id: ID of the user + session: Database session + + Returns: + Organization object + + Raises: + OrganizationNotFoundError: If organization doesn't exist + InsufficientPermissionsError: If user is not a member + """ + # Check if user is a member of the organization + user_org = session.exec( + select(UserOrganizationLink).where( + and_( + UserOrganizationLink.user_id == user_id, + UserOrganizationLink.organization_id == org_id + ) + ) + ).first() + + if not user_org: + raise InsufficientPermissionsError() + + db_org = session.get(Organization, org_id) + if not db_org or db_org.deleted: + raise OrganizationNotFoundError() + + return db_org + + +def check_user_permission( + user_id: int, + org_id: int, + permission: ValidPermissions, + session: Session, ) -> bool: """ - Check if user has the specified permission for the organization + Check if user has the specified permission for the organization. + + Args: + user_id: ID of the user + org_id: ID of the organization + permission: Permission to check + session: Database session + + Returns: + True if user has permission, False otherwise """ # Get user's role in the organization user_org = session.exec( select(UserOrganizationLink).where( and_( - UserOrganizationLink.user_id == user.id, + UserOrganizationLink.user_id == user_id, UserOrganizationLink.organization_id == org_id ) ) @@ -146,6 +224,8 @@ def check_user_permission( return bool(role_permission) +# -- Routes -- + @router.post("/", response_class=RedirectResponse) def create_organization( org: OrganizationCreate = Depends(OrganizationCreate.as_form), @@ -191,43 +271,17 @@ def create_organization( return RedirectResponse(url=f"/organizations/{db_org.id}", status_code=303) -@router.get("/{org_id}", response_model=OrganizationRead) -def read_organization( - org_id: int, - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) -): - # First check if user is a member of the organization - user_org = session.exec( - select(UserOrganizationLink).where( - and_( - UserOrganizationLink.user_id == user.id, - UserOrganizationLink.organization_id == org_id - ) - ) - ).first() - - if not user_org: - raise InsufficientPermissionsError() - - db_org = session.get(Organization, org_id) - if not db_org: - raise OrganizationNotFoundError() - return db_org - - @router.put("/{org_id}", response_class=RedirectResponse) def update_organization( org: OrganizationUpdate = Depends(OrganizationUpdate.as_form), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - if not check_user_permission(session, user, org.id, ValidPermissions.EDIT_ORGANIZATION): - raise InsufficientPermissionsError() + # This will raise appropriate exceptions if org doesn't exist or user lacks access + organization = get_organization(org.id, user.id, session) - db_org = session.get(Organization, org.id) - if not db_org: - raise OrganizationNotFoundError() + if not check_user_permission(user.id, org.id, ValidPermissions.EDIT_ORGANIZATION, session): + raise InsufficientPermissionsError() # Check if new name already exists for another organization existing_org = session.exec( @@ -238,11 +292,11 @@ def update_organization( if existing_org: raise OrganizationNameTakenError() - db_org.name = org.name - db_org.updated_at = datetime.utcnow() - session.add(db_org) + organization.name = org.name + organization.updated_at = utc_time() + session.add(organization) session.commit() - session.refresh(db_org) + session.refresh(organization) return RedirectResponse(url=f"/organizations/{org.id}", status_code=303) @@ -253,16 +307,15 @@ def delete_organization( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - if not check_user_permission(session, user, org_id, ValidPermissions.DELETE_ORGANIZATION): - raise InsufficientPermissionsError() + # This will raise appropriate exceptions if org doesn't exist or user lacks access + organization = get_organization(org_id, user.id, session) - db_org = session.get(Organization, org_id) - if not db_org: - raise OrganizationNotFoundError() + if not check_user_permission(user.id, org_id, ValidPermissions.DELETE_ORGANIZATION, session): + raise InsufficientPermissionsError() - db_org.deleted = True - db_org.updated_at = datetime.utcnow() - session.add(db_org) + organization.deleted = True + organization.updated_at = utc_time() + session.add(organization) session.commit() return RedirectResponse(url="/organizations", status_code=303) diff --git a/routers/role.py b/routers/role.py index 765331d..5724472 100644 --- a/routers/role.py +++ b/routers/role.py @@ -107,6 +107,31 @@ async def as_form( ) +# -- Helper Functions -- + +def get_organization_roles( + organization_id: int, + session: Session, + include_deleted: bool = False +) -> List[Role]: + """ + Retrieve all roles for an organization. + + Args: + organization_id: ID of the organization + session: Database session + include_deleted: Whether to include soft-deleted roles + + Returns: + List of Role objects with their associated permissions + """ + query = select(Role).where(Role.organization_id == organization_id) + if not include_deleted: + query = query.where(Role.deleted == False) + + return list(session.exec(query)) + + # -- Routes -- @@ -117,7 +142,10 @@ def create_role( session: Session = Depends(get_session) ) -> RedirectResponse: # Create role and permissions in a single transaction - db_role = Role(name=role.name) + db_role = Role( + name=role.name, + organization_id=user.organization_id # Add organization ID to role + ) # Create RolePermissionLink objects and associate them with the role db_role.permissions = [ @@ -131,32 +159,6 @@ def create_role( return RedirectResponse(url="/roles", status_code=303) -@router.get("/{role_id}", response_model=RoleRead) -def read_role( - role_id: int, - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) -): - db_role: Role | None = session.get(Role, role_id) - if not db_role or not db_role.id or db_role.deleted: - raise RoleNotFoundError() - - permissions = [ - ValidPermissions(link.permission.name) - for link in db_role.role_permission_links - if link.permission is not None - ] - - return RoleRead( - id=db_role.id, - name=db_role.name, - created_at=db_role.created_at, - updated_at=db_role.updated_at, - deleted=db_role.deleted, - permissions=permissions - ) - - @router.put("/{role_id}", response_class=RedirectResponse) def update_role( role: RoleUpdate = Depends(RoleUpdate.as_form), From 4542f23dc7933d071990453e33f2c5b915b4d798 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Thu, 28 Nov 2024 15:14:56 +0000 Subject: [PATCH 36/73] Added preliminary organizations.html component, moved some helpers to utils/role_org.py, hooked it all up --- main.py | 11 +- routers/organization.py | 132 +------------------ routers/role.py | 31 +---- templates/components/organizations.html | 44 +++++++ templates/users/profile.html | 4 + utils/role_org.py | 164 ++++++++++++++++++++++++ 6 files changed, 228 insertions(+), 158 deletions(-) create mode 100644 templates/components/organizations.html create mode 100644 utils/role_org.py diff --git a/main.py b/main.py index 676808e..5c174d6 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError from utils.models import User from utils.db import get_session, set_up_db - +from utils.role_org import get_user_organizations, get_organization_roles logger = logging.getLogger("uvicorn.error") logger.setLevel(logging.DEBUG) @@ -242,11 +242,16 @@ async def read_dashboard( @app.get("/profile") async def read_profile( - params: dict = Depends(common_authenticated_parameters) + params: dict = Depends(common_authenticated_parameters), + session: Session = Depends(get_session) ): if not params["user"]: - # Changed to 302 return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) + + # Get user's organizations + params["organizations"] = get_user_organizations( + params["user"].id, session) + return templates.TemplateResponse(params["request"], "users/profile.html", params) diff --git a/routers/organization.py b/routers/organization.py index 078fe17..9d55cc8 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -5,10 +5,10 @@ from sqlmodel import Session, select from utils.db import get_session from utils.auth import get_authenticated_user -from utils.models import Organization, User, Role, UserOrganizationLink, ValidPermissions, RolePermissionLink, Permission, utc_time +from utils.models import Organization, User, Role, UserOrganizationLink, ValidPermissions, utc_time from datetime import datetime from sqlalchemy import and_ -from typing import List +from utils.role_org import get_organization, check_user_permission logger = getLogger("uvicorn.error") @@ -102,128 +102,6 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): return cls(id=id, name=name) -# -- Helper Functions -- - -def get_user_organizations( - user_id: int, - session: Session, - include_deleted: bool = False -) -> List[Organization]: - """ - Retrieve all organizations a user is a member of. - - Args: - user_id: ID of the user - session: Database session - include_deleted: Whether to include soft-deleted organizations - - Returns: - List of Organization objects the user belongs to - """ - query = ( - select(Organization) - .join(UserOrganizationLink) - .where(UserOrganizationLink.user_id == user_id) - ) - - if not include_deleted: - query = query.where(Organization.deleted == False) - - return list(session.exec(query)) - - -def get_organization( - org_id: int, - user_id: int, - session: Session, -) -> Organization: - """ - Retrieve a specific organization if the user is a member. - - Args: - org_id: ID of the organization - user_id: ID of the user - session: Database session - - Returns: - Organization object - - Raises: - OrganizationNotFoundError: If organization doesn't exist - InsufficientPermissionsError: If user is not a member - """ - # Check if user is a member of the organization - user_org = session.exec( - select(UserOrganizationLink).where( - and_( - UserOrganizationLink.user_id == user_id, - UserOrganizationLink.organization_id == org_id - ) - ) - ).first() - - if not user_org: - raise InsufficientPermissionsError() - - db_org = session.get(Organization, org_id) - if not db_org or db_org.deleted: - raise OrganizationNotFoundError() - - return db_org - - -def check_user_permission( - user_id: int, - org_id: int, - permission: ValidPermissions, - session: Session, -) -> bool: - """ - Check if user has the specified permission for the organization. - - Args: - user_id: ID of the user - org_id: ID of the organization - permission: Permission to check - session: Database session - - Returns: - True if user has permission, False otherwise - """ - # Get user's role in the organization - user_org = session.exec( - select(UserOrganizationLink).where( - and_( - UserOrganizationLink.user_id == user_id, - UserOrganizationLink.organization_id == org_id - ) - ) - ).first() - - if not user_org: - return False - - # Get permission ID - permission_record = session.exec( - select(Permission).where(Permission.name == permission) - ).first() - - if not permission_record: - return False - - # Check if role has the permission - role_permission = session.exec( - select(RolePermissionLink).where( - and_( - RolePermissionLink.role_id == user_org.role_id, - RolePermissionLink.permission_id == permission_record.id - ) - ) - ).first() - - return bool(role_permission) - - # -- Routes -- @router.post("/", response_class=RedirectResponse) @@ -268,7 +146,7 @@ def create_organization( session.add(user_org_link) session.commit() - return RedirectResponse(url=f"/organizations/{db_org.id}", status_code=303) + return RedirectResponse(url=f"/profile", status_code=303) @router.put("/{org_id}", response_class=RedirectResponse) @@ -298,7 +176,7 @@ def update_organization( session.commit() session.refresh(organization) - return RedirectResponse(url=f"/organizations/{org.id}", status_code=303) + return RedirectResponse(url=f"/profile", status_code=303) @router.delete("/{org_id}", response_class=RedirectResponse) @@ -318,4 +196,4 @@ def delete_organization( session.add(organization) session.commit() - return RedirectResponse(url="/organizations", status_code=303) + return RedirectResponse(url="/profile", status_code=303) diff --git a/routers/role.py b/routers/role.py index 5724472..0a0230e 100644 --- a/routers/role.py +++ b/routers/role.py @@ -107,31 +107,6 @@ async def as_form( ) -# -- Helper Functions -- - -def get_organization_roles( - organization_id: int, - session: Session, - include_deleted: bool = False -) -> List[Role]: - """ - Retrieve all roles for an organization. - - Args: - organization_id: ID of the organization - session: Database session - include_deleted: Whether to include soft-deleted roles - - Returns: - List of Role objects with their associated permissions - """ - query = select(Role).where(Role.organization_id == organization_id) - if not include_deleted: - query = query.where(Role.deleted == False) - - return list(session.exec(query)) - - # -- Routes -- @@ -156,7 +131,7 @@ def create_role( session.add(db_role) session.commit() - return RedirectResponse(url="/roles", status_code=303) + return RedirectResponse(url="/profile", status_code=303) @router.put("/{role_id}", response_class=RedirectResponse) @@ -186,7 +161,7 @@ def update_role( session.commit() session.refresh(db_role) - return RedirectResponse(url=f"/roles/{role.id}", status_code=303) + return RedirectResponse(url="/profile", status_code=303) @router.delete("/{role_id}", response_class=RedirectResponse) @@ -203,4 +178,4 @@ def delete_role( db_role.updated_at = utc_time() session.add(db_role) session.commit() - return RedirectResponse(url="/roles", status_code=303) + return RedirectResponse(url="/profile", status_code=303) diff --git a/templates/components/organizations.html b/templates/components/organizations.html new file mode 100644 index 0000000..85c24d4 --- /dev/null +++ b/templates/components/organizations.html @@ -0,0 +1,44 @@ +{% macro render_organizations(organizations) %} +
+
+ Organizations + +
+
+ +
+ +
+ + +
+ + +
+ + + {% if organizations %} +
+ {% for org in organizations %} + +
+
{{ org.name }}
+ Joined {{ org.created_at.strftime('%Y-%m-%d') }} +
+
+ {% endfor %} +
+ {% else %} +

You are not a member of any organizations.

+ {% endif %} +
+
+{% endmacro %} diff --git a/templates/users/profile.html b/templates/users/profile.html index 5afa794..39725f2 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% from 'components/silhouette.html' import render_silhouette %} +{% from 'components/organizations.html' import render_organizations %} {% block title %}Profile{% endblock %} @@ -67,6 +68,9 @@

User Profile

+ + {{ render_organizations(organizations) }} +
diff --git a/utils/role_org.py b/utils/role_org.py new file mode 100644 index 0000000..3e852d7 --- /dev/null +++ b/utils/role_org.py @@ -0,0 +1,164 @@ +from typing import List +from sqlmodel import Session, select +from sqlalchemy import and_ +from fastapi import HTTPException +from utils.models import Organization, Role, UserOrganizationLink, RolePermissionLink, Permission, ValidPermissions + + +class OrganizationNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=404, + detail="Organization not found" + ) + + +class InsufficientPermissionsError(HTTPException): + def __init__(self): + super().__init__( + status_code=403, + detail="You don't have permission to perform this action" + ) + + +def get_user_organizations( + user_id: int, + session: Session, + include_deleted: bool = False +) -> List[Organization]: + """ + Retrieve all organizations a user is a member of. + + Args: + user_id: ID of the user + session: Database session + include_deleted: Whether to include soft-deleted organizations + + Returns: + List of Organization objects the user belongs to + """ + query = ( + select(Organization) + .join(UserOrganizationLink) + .where(UserOrganizationLink.user_id == user_id) + ) + + if not include_deleted: + query = query.where(Organization.deleted == False) + + return list(session.exec(query)) + + +def get_organization( + org_id: int, + user_id: int, + session: Session, +) -> Organization: + """ + Retrieve a specific organization if the user is a member. + + Args: + org_id: ID of the organization + user_id: ID of the user + session: Database session + + Returns: + Organization object + + Raises: + OrganizationNotFoundError: If organization doesn't exist + InsufficientPermissionsError: If user is not a member + """ + # Check if user is a member of the organization + user_org = session.exec( + select(UserOrganizationLink).where( + and_( + UserOrganizationLink.user_id == user_id, + UserOrganizationLink.organization_id == org_id + ) + ) + ).first() + + if not user_org: + raise InsufficientPermissionsError() + + db_org = session.get(Organization, org_id) + if not db_org or db_org.deleted: + raise OrganizationNotFoundError() + + return db_org + + +def check_user_permission( + user_id: int, + org_id: int, + permission: ValidPermissions, + session: Session, +) -> bool: + """ + Check if user has the specified permission for the organization. + + Args: + user_id: ID of the user + org_id: ID of the organization + permission: Permission to check + session: Database session + + Returns: + True if user has permission, False otherwise + """ + # Get user's role in the organization + user_org = session.exec( + select(UserOrganizationLink).where( + and_( + UserOrganizationLink.user_id == user_id, + UserOrganizationLink.organization_id == org_id + ) + ) + ).first() + + if not user_org: + return False + + # Get permission ID + permission_record = session.exec( + select(Permission).where(Permission.name == permission) + ).first() + + if not permission_record: + return False + + # Check if role has the permission + role_permission = session.exec( + select(RolePermissionLink).where( + and_( + RolePermissionLink.role_id == user_org.role_id, + RolePermissionLink.permission_id == permission_record.id + ) + ) + ).first() + + return bool(role_permission) + + +def get_organization_roles( + organization_id: int, + session: Session, + include_deleted: bool = False +) -> List[Role]: + """ + Retrieve all roles for an organization. + + Args: + organization_id: ID of the organization + session: Database session + include_deleted: Whether to include soft-deleted roles + + Returns: + List of Role objects with their associated permissions + """ + query = select(Role).where(Role.organization_id == organization_id) + if not include_deleted: + query = query.where(Role.deleted == False) + + return list(session.exec(query)) From eb58d4d445f05138ecfb785eb70a08d645edd8f2 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Thu, 28 Nov 2024 17:11:50 +0000 Subject: [PATCH 37/73] Default permissions will be global, but default roles will be organization-specific --- main.py | 3 ++- routers/organization.py | 11 ++++++++- tests/test_models.py | 0 utils/db.py | 55 ++++++++++++++++++++++++++--------------- utils/models.py | 15 ++++++++++- utils/role_org.py | 9 +++++-- 6 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 tests/test_models.py diff --git a/main.py b/main.py index 477c348..392d251 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,8 @@ @asynccontextmanager async def lifespan(app: FastAPI): # Optional startup logic - set_up_db(drop=False) + # TODO: Set drop=False in production + set_up_db(drop=True) yield # Optional shutdown logic diff --git a/routers/organization.py b/routers/organization.py index 58a68df..d030bb9 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -5,7 +5,7 @@ from sqlmodel import Session, select from utils.db import get_session from utils.auth import get_authenticated_user -from utils.models import Organization, User, Role, UserOrganizationLink, ValidPermissions, utc_time +from utils.models import Organization, User, Role, Permission, UserOrganizationLink, ValidPermissions, utc_time from datetime import datetime from sqlalchemy import and_ from utils.role_org import get_organization, check_user_permission @@ -119,6 +119,15 @@ def create_organization( session.commit() session.refresh(db_org) + # Create default roles + default_role_names = ["Owner", "Administrator", "Member"] + default_roles = [] + for role_name in default_role_names: + role = Role(name=role_name, organization_id=db_org.id) + session.add(role) + default_roles.append(role) + session.commit() + owner_role = session.exec( select(Role).where( and_( diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/db.py b/utils/db.py index e044591..3427a4f 100644 --- a/utils/db.py +++ b/utils/db.py @@ -39,24 +39,51 @@ def get_session(): yield session -def create_roles(session): +def create_default_roles(session, organization_id: int, check_first: bool = True): """ - Create default roles in the database if they do not exist. + Create default roles for an organization in the database if they do not exist. """ roles_in_db = [] for role_name in default_roles: db_role = session.exec(select(Role).where( - Role.name == role_name)).first() + Role.name == role_name, + Role.organization_id == organization_id + )).first() if not db_role: - db_role = Role(name=role_name) + db_role = Role(name=role_name, organization_id=organization_id) session.add(db_role) roles_in_db.append(db_role) + + # Create RolePermissionLink for Owner and Administrator roles + for role in roles_in_db[:2]: + permissions = session.exec(select(Permission)).all() + for permission in permissions: + # Check if the role already has the permission + if check_first: + db_role_permission_link: RolePermissionLink | None = session.exec(select(RolePermissionLink).where( + RolePermissionLink.role_id == role.id, + RolePermissionLink.permission_id == permission.id + )).first() + else: + db_role_permission_link = None + + # Skip giving DELETE_ORGANIZATION permission to Administrator + if not db_role_permission_link and not ( + permission == ValidPermissions.DELETE_ORGANIZATION and + role.name == "Administrator" + ): + role_permission_link = RolePermissionLink( + role_id=role.id, + permission_id=permission.id + ) + session.add(role_permission_link) + return roles_in_db -def create_permissions(session, roles_in_db): +def create_permissions(session): """ - Create default permissions and link them to roles in the database. + Create default permissions. """ for permission in ValidPermissions: db_permission = session.exec(select(Permission).where( @@ -65,17 +92,6 @@ def create_permissions(session, roles_in_db): db_permission = Permission(name=permission) session.add(db_permission) - # Create RolePermissionLink for Owner and Administrator - for role in roles_in_db[:2]: - db_role_permission_link = session.exec(select(RolePermissionLink).where( - RolePermissionLink.role_id == role.id, - RolePermissionLink.permission_id == db_permission.id)).first() - if not db_role_permission_link: - if not (permission == ValidPermissions.DELETE_ORGANIZATION and role.name == "Administrator"): - role_permission_link = RolePermissionLink( - role_id=role.id, permission_id=db_permission.id) - session.add(role_permission_link) - def set_up_db(drop: bool = False): """ @@ -85,10 +101,9 @@ def set_up_db(drop: bool = False): if drop: SQLModel.metadata.drop_all(engine) SQLModel.metadata.create_all(engine) + # Create default permissions with Session(engine) as session: - roles_in_db = create_roles(session) - session.commit() - create_permissions(session, roles_in_db) + create_permissions(session) session.commit() engine.dispose() diff --git a/utils/models.py b/utils/models.py index 4d5fbaa..107e7fa 100644 --- a/utils/models.py +++ b/utils/models.py @@ -62,13 +62,23 @@ class Organization(SQLModel, table=True): class Role(SQLModel, table=True): + """ + Represents a role within an organization. + + Attributes: + id: Primary key. + name: The name of the role. + organization_id: Foreign key to the associated organization. + created_at: Timestamp when the role was created. + updated_at: Timestamp when the role was last updated. + """ id: Optional[int] = Field(default=None, primary_key=True) name: str organization_id: int = Field(foreign_key="organization.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - organization: Organization = Relationship(back_populates="roles") + organization: "Organization" = Relationship(back_populates="roles") user_links: List[UserOrganizationLink] = Relationship( back_populates="role") permissions: List["Permission"] = Relationship( @@ -78,6 +88,9 @@ class Role(SQLModel, table=True): class Permission(SQLModel, table=True): + """ + Represents a permission that can be assigned to a role. + """ id: Optional[int] = Field(default=None, primary_key=True) name: ValidPermissions = Field( sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False))) diff --git a/utils/role_org.py b/utils/role_org.py index 4431e59..adb61b5 100644 --- a/utils/role_org.py +++ b/utils/role_org.py @@ -1,6 +1,6 @@ from typing import List from sqlmodel import Session, select -from sqlalchemy import and_ +from sqlalchemy import and_, or_ from fastapi import HTTPException from utils.models import Organization, Role, UserOrganizationLink, RolePermissionLink, Permission, ValidPermissions @@ -150,6 +150,11 @@ def get_organization_roles( Returns: List of Role objects with their associated permissions """ - query = select(Role).where(Role.organization_id == organization_id) + query = select(Role).where( + or_( + Role.organization_id == organization_id, + Role.organization_id == None + ) + ) return list(session.exec(query)) From f99af6897144345ac6a2cded42c2cd7aa3e2e1e1 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Thu, 28 Nov 2024 19:08:41 +0000 Subject: [PATCH 38/73] Refactored db.py --- utils/db.py | 146 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 106 insertions(+), 40 deletions(-) diff --git a/utils/db.py b/utils/db.py index 3427a4f..a38018e 100644 --- a/utils/db.py +++ b/utils/db.py @@ -1,23 +1,34 @@ import os import logging +from typing import Generator from dotenv import load_dotenv from sqlalchemy.engine import URL from sqlmodel import create_engine, Session, SQLModel, select from utils.models import Role, Permission, RolePermissionLink, default_roles, ValidPermissions +# Load environment variables from a .env file load_dotenv() +# Set up a logger for error reporting logger = logging.getLogger("uvicorn.error") -# --- Database connection --- +# --- Database connection functions --- def get_connection_url() -> URL: """ - Creates a SQLModel URL object containing the connection URL to the Postgres database. - The connection details are obtained from environment variables. - Returns the URL object. + Constructs a SQLModel URL object for connecting to the PostgreSQL database. + + The connection details are sourced from environment variables, which should include: + - DB_USER: Database username + - DB_PASSWORD: Database password + - DB_HOST: Database host address + - DB_PORT: Database port (default is 5432) + - DB_NAME: Database name + + Returns: + URL: A SQLModel URL object containing the connection details. """ database_url: URL = URL.create( drivername="postgresql", @@ -31,59 +42,111 @@ def get_connection_url() -> URL: return database_url +# Create the database engine using the connection URL engine = create_engine(get_connection_url()) -def get_session(): +def get_session() -> Generator[Session, None, None]: + """ + Provides a database session for executing queries. + + Yields: + Session: A SQLModel session object for database operations. + """ with Session(engine) as session: yield session -def create_default_roles(session, organization_id: int, check_first: bool = True): +def assign_permissions_to_role(session: Session, role: Role, permissions: list[Permission], check_first: bool = False) -> None: + """ + Assigns permissions to a role in the database. + + Args: + session (Session): The database session to use for operations. + role (Role): The role to assign permissions to. + permissions (list[Permission]): The list of permissions to assign. + check_first (bool): If True, checks if the role already has the permission before assigning it. + """ + + for permission in permissions: + # Check if the role already has the permission + if check_first: + db_role_permission_link: RolePermissionLink | None = session.exec( + select(RolePermissionLink).where( + RolePermissionLink.role_id == role.id, + RolePermissionLink.permission_id == permission.id + ) + ).first() + else: + db_role_permission_link = None + + # Skip granting DELETE_ORGANIZATION permission to the Administrator role + if not db_role_permission_link: + role_permission_link = RolePermissionLink( + role_id=role.id, + permission_id=permission.id + ) + session.add(role_permission_link) + + +def create_default_roles(session: Session, organization_id: int, check_first: bool = True) -> list: """ - Create default roles for an organization in the database if they do not exist. + Creates default roles for a specified organization in the database if they do not already exist, + and assigns permissions to the Owner and Administrator roles. + + Args: + session (Session): The database session to use for operations. + organization_id (int): The ID of the organization for which to create roles. + check_first (bool): If True, checks if the role already exists before creating it. + + Returns: + list: A list of roles that were created or already existed in the database. """ + roles_in_db = [] for role_name in default_roles: - db_role = session.exec(select(Role).where( - Role.name == role_name, - Role.organization_id == organization_id - )).first() + db_role = session.exec( + select(Role).where( + Role.name == role_name, + Role.organization_id == organization_id + ) + ).first() if not db_role: db_role = Role(name=role_name, organization_id=organization_id) session.add(db_role) roles_in_db.append(db_role) - # Create RolePermissionLink for Owner and Administrator roles - for role in roles_in_db[:2]: - permissions = session.exec(select(Permission)).all() - for permission in permissions: - # Check if the role already has the permission - if check_first: - db_role_permission_link: RolePermissionLink | None = session.exec(select(RolePermissionLink).where( - RolePermissionLink.role_id == role.id, - RolePermissionLink.permission_id == permission.id - )).first() - else: - db_role_permission_link = None - - # Skip giving DELETE_ORGANIZATION permission to Administrator - if not db_role_permission_link and not ( - permission == ValidPermissions.DELETE_ORGANIZATION and - role.name == "Administrator" - ): - role_permission_link = RolePermissionLink( - role_id=role.id, - permission_id=permission.id - ) - session.add(role_permission_link) + # TODO: Construct this role-permission mapping once at app startup and use as constant + # Fetch all permissions once + owner_permissions = session.exec(select(Permission)).all() + admin_permissions = [ + permission for permission in owner_permissions + if permission.name != ValidPermissions.DELETE_ORGANIZATION + ] + + # Get Owner and Administrator roles by name + owner_role = next(role for role in roles_in_db if role.name == "Owner") + admin_role = next( + role for role in roles_in_db if role.name == "Administrator") + + # Assign all permissions to Owner + assign_permissions_to_role( + session, owner_role, owner_permissions, check_first=check_first) + # Assign filtered permissions to Administrator + assign_permissions_to_role( + session, admin_role, admin_permissions, check_first=check_first) + + session.commit() return roles_in_db -def create_permissions(session): +def create_permissions(session: Session) -> None: """ - Create default permissions. + Creates default permissions in the database if they do not already exist. + + Args: + session (Session): The database session to use for operations. """ for permission in ValidPermissions: db_permission = session.exec(select(Permission).where( @@ -93,9 +156,12 @@ def create_permissions(session): session.add(db_permission) -def set_up_db(drop: bool = False): +def set_up_db(drop: bool = False) -> None: """ - Set up the database by creating tables and populating them with default roles and permissions. + Sets up the database by creating tables and populating them with default permissions. + + Args: + drop (bool): If True, drops all existing tables before creating new ones. """ engine = create_engine(get_connection_url()) if drop: @@ -108,9 +174,9 @@ def set_up_db(drop: bool = False): engine.dispose() -def tear_down_db(): +def tear_down_db() -> None: """ - Tear down the database by dropping all tables. + Tears down the database by dropping all tables. """ engine = create_engine(get_connection_url()) SQLModel.metadata.drop_all(engine) From b959ff0a5c366a1db860da9e9f7a6d9f16f28ae4 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Thu, 28 Nov 2024 19:08:56 +0000 Subject: [PATCH 39/73] Passing tests for utils/db.py helpers --- tests/conftest.py | 11 +++- tests/test_db.py | 122 +++++++++++++++++++++++++++++++++++++++++++ tests/test_models.py | 55 +++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/test_db.py diff --git a/tests/conftest.py b/tests/conftest.py index f9b90ae..f7e06d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from sqlmodel import create_engine, Session, delete from fastapi.testclient import TestClient from utils.db import get_connection_url, set_up_db, tear_down_db, get_session -from utils.models import User, PasswordResetToken +from utils.models import User, PasswordResetToken, Organization from utils.auth import get_password_hash, create_access_token, create_refresh_token from main import app @@ -107,3 +107,12 @@ def get_session_override(): yield client app.dependency_overrides.clear() + + +@pytest.fixture +def test_organization(session: Session): + """Create a test organization for use in tests""" + organization = Organization(name="Test Organization") + session.add(organization) + session.commit() + return organization diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..2c47edf --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,122 @@ +from sqlmodel import Session, select +from utils.db import ( + get_connection_url, + assign_permissions_to_role, + create_default_roles, + create_permissions, +) +from utils.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions + + +def test_get_connection_url(): + """Test that get_connection_url returns a valid URL object""" + url = get_connection_url() + assert url.drivername == "postgresql" + assert url.database is not None + + +def test_create_permissions(session: Session): + """Test that create_permissions creates all ValidPermissions""" + # Clear existing permissions + existing_permissions = session.exec(select(Permission)).all() + for permission in existing_permissions: + session.delete(permission) + session.commit() + + create_permissions(session) + session.commit() + + # Check all permissions were created + db_permissions = session.exec(select(Permission)).all() + assert len(db_permissions) == len(ValidPermissions) + assert {p.name for p in db_permissions} == {p for p in ValidPermissions} + + +def test_create_default_roles(session: Session, test_organization: Organization): + """Test that create_default_roles creates expected roles with correct permissions""" + # Create permissions first + create_permissions(session) + session.commit() + + # Create roles for test organization + roles = create_default_roles(session, test_organization.id) + session.commit() + + # Verify roles were created + assert len(roles) == 3 # Owner, Administrator, Member + + # Check Owner role permissions + owner_role = next(r for r in roles if r.name == "Owner") + owner_permissions = session.exec( + select(Permission) + .join(RolePermissionLink) + .where(RolePermissionLink.role_id == owner_role.id) + ).all() + assert len(owner_permissions) == len(ValidPermissions) + + # Check Administrator role permissions + admin_role = next(r for r in roles if r.name == "Administrator") + admin_permissions = session.exec( + select(Permission) + .join(RolePermissionLink) + .where(RolePermissionLink.role_id == admin_role.id) + ).all() + # Admin should have all permissions except DELETE_ORGANIZATION + assert len(admin_permissions) == len(ValidPermissions) - 1 + assert ValidPermissions.DELETE_ORGANIZATION not in { + p.name for p in admin_permissions} + + +def test_assign_permissions_to_role(session: Session, test_organization: Organization): + """Test that assign_permissions_to_role correctly assigns permissions""" + # Create a test role with the organization from fixture + role = Role(name="Test Role", organization_id=test_organization.id) + session.add(role) + + # Create test permissions + perm1 = Permission(name=ValidPermissions.CREATE_ROLE) + perm2 = Permission(name=ValidPermissions.DELETE_ROLE) + session.add(perm1) + session.add(perm2) + session.commit() + + # Assign permissions + permissions = [perm1, perm2] + assign_permissions_to_role(session, role, permissions) + session.commit() + + # Verify assignments + db_permissions = session.exec( + select(Permission) + .join(RolePermissionLink) + .where(RolePermissionLink.role_id == role.id) + ).all() + + assert len(db_permissions) == 2 + assert {p.name for p in db_permissions} == { + ValidPermissions.CREATE_ROLE, ValidPermissions.DELETE_ROLE} + + +def test_assign_permissions_to_role_duplicate_check(session: Session, test_organization: Organization): + """Test that assign_permissions_to_role doesn't create duplicates""" + # Create a test role with the organization from fixture + role = Role(name="Test Role", organization_id=test_organization.id) + perm = Permission(name=ValidPermissions.CREATE_ROLE) + session.add(role) + session.add(perm) + session.commit() + + # Assign same permission twice + assign_permissions_to_role(session, role, [perm], check_first=True) + assign_permissions_to_role(session, role, [perm], check_first=True) + session.commit() + + # Verify only one assignment exists + link_count = session.exec( + select(RolePermissionLink) + .where( + RolePermissionLink.role_id == role.id, + RolePermissionLink.permission_id == perm.id + ) + ).all() + assert len(link_count) == 1 diff --git a/tests/test_models.py b/tests/test_models.py index e69de29..10bb50c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -0,0 +1,55 @@ +import pytest +from sqlmodel import select, Session +from utils.models import ( + Permission, + Role, + RolePermissionLink, + Organization, + ValidPermissions, +) + + +def test_permissions_persist_after_role_deletion(session: Session): + """ + Test that permissions are not deleted when a related Role is deleted. + """ + # Create an organization + organization = Organization(name="Test Organization") + session.add(organization) + session.commit() + session.refresh(organization) + + # Create permissions + permission1 = Permission(name=ValidPermissions.DELETE_ORGANIZATION) + permission2 = Permission(name=ValidPermissions.EDIT_ORGANIZATION) + session.add_all([permission1, permission2]) + session.commit() + + # Create a role and link permissions + role = Role(name="Test Role", organization_id=organization.id) + session.add(role) + session.commit() + session.refresh(role) + + role_permission_link1 = RolePermissionLink( + role_id=role.id, permission_id=permission1.id + ) + role_permission_link2 = RolePermissionLink( + role_id=role.id, permission_id=permission2.id + ) + session.add_all([role_permission_link1, role_permission_link2]) + session.commit() + + # Delete the role + session.delete(role) + session.commit() + + # Verify that permissions still exist + remaining_permissions = session.exec(select(Permission)).all() + assert len(remaining_permissions) == 2 + assert permission1 in remaining_permissions + assert permission2 in remaining_permissions + + # Verify that RolePermissionLinks are deleted + remaining_role_permissions = session.exec(select(RolePermissionLink)).all() + assert len(remaining_role_permissions) == 0 From 890e7cc12d593029ccef14d4e2f0af3c4ecfd622 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Thu, 28 Nov 2024 19:32:14 +0000 Subject: [PATCH 40/73] Test db setup helpers --- tests/test_db.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/test_db.py b/tests/test_db.py index 2c47edf..c0bbc48 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,11 +1,15 @@ -from sqlmodel import Session, select +import warnings +from sqlmodel import Session, select, inspect from utils.db import ( get_connection_url, assign_permissions_to_role, create_default_roles, create_permissions, + tear_down_db, + set_up_db, ) from utils.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions +from sqlalchemy import create_engine def test_get_connection_url(): @@ -120,3 +124,66 @@ def test_assign_permissions_to_role_duplicate_check(session: Session, test_organ ) ).all() assert len(link_count) == 1 + + +def test_set_up_db_creates_tables(): + """Test that set_up_db creates all expected tables without warnings""" + # First tear down any existing tables + tear_down_db() + + # Run set_up_db with drop=False since we just cleaned up + set_up_db(drop=False) + + # Use SQLAlchemy inspect to check tables + engine = create_engine(get_connection_url()) + inspector = inspect(engine) + table_names = inspector.get_table_names() + + # Check for core tables + expected_tables = { + "user", + "organization", + "role", + "permission", + "role_permission_link", + "password_reset_token" + } + assert expected_tables.issubset(set(table_names)) + + # Verify permissions were created + with Session(engine) as session: + permissions = session.exec(select(Permission)).all() + assert len(permissions) == len(ValidPermissions) + + # Clean up + tear_down_db() + engine.dispose() + + +def test_set_up_db_drop_flag(): + """Test that set_up_db's drop flag properly recreates tables""" + # Set up db with drop=True + engine = create_engine(get_connection_url()) + set_up_db(drop=True) + + # Create a new session for this test + with Session(engine) as session: + # Verify valid permissions exist + permissions = session.exec(select(Permission)).all() + assert len(permissions) == len(ValidPermissions) + + # Create an organization + org = Organization(name="Test Organization") + session.add(org) + session.commit() + + # Set up db with drop=False + set_up_db(drop=False) + + # Verify organization exists + assert session.exec(select(Organization).where( + Organization.name == "Test Organization")).first() is not None + + # Clean up + tear_down_db() + engine.dispose() From 91bc397311fc0f2077fc4bee5d36e7f098e67c55 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Fri, 29 Nov 2024 01:45:17 +0000 Subject: [PATCH 41/73] Correct association table relationships to remove overlaps and silence warnings --- tests/conftest.py | 14 +++++--- tests/test_db.py | 51 +++++++++++----------------- tests/test_models.py | 39 +++++++++++++-------- utils/models.py | 81 +++++++++++++++++++++++++------------------- 4 files changed, 100 insertions(+), 85 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f7e06d4..a6dbb2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest from dotenv import load_dotenv -from sqlmodel import create_engine, Session, delete +from sqlmodel import create_engine, Session, select from fastapi.testclient import TestClient from utils.db import get_connection_url, set_up_db, tear_down_db, get_session from utils.models import User, PasswordResetToken, Organization @@ -47,9 +47,15 @@ def clean_db(session: Session): """ Cleans up the database tables before each test. """ - # Exempt from mypy until SQLModel overload properly supports delete() - session.exec(delete(PasswordResetToken)) # type: ignore - session.exec(delete(User)) # type: ignore + # Delete all PasswordResetTokens + tokens = session.exec(select(PasswordResetToken)).all() + for token in tokens: + session.delete(token) + + # Delete all Users + users = session.exec(select(User)).all() + for user in users: + session.delete(user) session.commit() diff --git a/tests/test_db.py b/tests/test_db.py index c0bbc48..fdd85cb 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -9,7 +9,7 @@ set_up_db, ) from utils.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions -from sqlalchemy import create_engine +from sqlalchemy import Engine def test_get_connection_url(): @@ -126,7 +126,7 @@ def test_assign_permissions_to_role_duplicate_check(session: Session, test_organ assert len(link_count) == 1 -def test_set_up_db_creates_tables(): +def test_set_up_db_creates_tables(engine: Engine, session: Session): """Test that set_up_db creates all expected tables without warnings""" # First tear down any existing tables tear_down_db() @@ -135,7 +135,6 @@ def test_set_up_db_creates_tables(): set_up_db(drop=False) # Use SQLAlchemy inspect to check tables - engine = create_engine(get_connection_url()) inspector = inspect(engine) table_names = inspector.get_table_names() @@ -145,45 +144,33 @@ def test_set_up_db_creates_tables(): "organization", "role", "permission", - "role_permission_link", - "password_reset_token" + "rolepermissionlink", + "passwordresettoken" } assert expected_tables.issubset(set(table_names)) # Verify permissions were created - with Session(engine) as session: - permissions = session.exec(select(Permission)).all() - assert len(permissions) == len(ValidPermissions) - - # Clean up - tear_down_db() - engine.dispose() + permissions = session.exec(select(Permission)).all() + assert len(permissions) == len(ValidPermissions) -def test_set_up_db_drop_flag(): +def test_set_up_db_drop_flag(engine: Engine, session: Session): """Test that set_up_db's drop flag properly recreates tables""" # Set up db with drop=True - engine = create_engine(get_connection_url()) set_up_db(drop=True) - # Create a new session for this test - with Session(engine) as session: - # Verify valid permissions exist - permissions = session.exec(select(Permission)).all() - assert len(permissions) == len(ValidPermissions) - - # Create an organization - org = Organization(name="Test Organization") - session.add(org) - session.commit() + # Verify valid permissions exist + permissions = session.exec(select(Permission)).all() + assert len(permissions) == len(ValidPermissions) - # Set up db with drop=False - set_up_db(drop=False) + # Create an organization + org = Organization(name="Test Organization") + session.add(org) + session.commit() - # Verify organization exists - assert session.exec(select(Organization).where( - Organization.name == "Test Organization")).first() is not None + # Set up db with drop=False + set_up_db(drop=False) - # Clean up - tear_down_db() - engine.dispose() + # Verify organization exists + assert session.exec(select(Organization).where( + Organization.name == "Test Organization")).first() is not None diff --git a/tests/test_models.py b/tests/test_models.py index 10bb50c..e0894a6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,44 +12,53 @@ def test_permissions_persist_after_role_deletion(session: Session): """ Test that permissions are not deleted when a related Role is deleted. + Permissions links are automatically deleted due to cascade_delete=True. """ + # Verify all ValidPermissions exist in database + all_permissions = session.exec(select(Permission)).all() + assert len(all_permissions) == len(ValidPermissions) + # Create an organization organization = Organization(name="Test Organization") session.add(organization) session.commit() session.refresh(organization) - # Create permissions - permission1 = Permission(name=ValidPermissions.DELETE_ORGANIZATION) - permission2 = Permission(name=ValidPermissions.EDIT_ORGANIZATION) - session.add_all([permission1, permission2]) - session.commit() - - # Create a role and link permissions + # Create a role and link two specific permissions role = Role(name="Test Role", organization_id=organization.id) session.add(role) session.commit() session.refresh(role) + # Find specific permissions to link + delete_org_permission = next( + p for p in all_permissions if p.name == ValidPermissions.DELETE_ORGANIZATION) + edit_org_permission = next( + p for p in all_permissions if p.name == ValidPermissions.EDIT_ORGANIZATION) + role_permission_link1 = RolePermissionLink( - role_id=role.id, permission_id=permission1.id + role_id=role.id, permission_id=delete_org_permission.id ) role_permission_link2 = RolePermissionLink( - role_id=role.id, permission_id=permission2.id + role_id=role.id, permission_id=edit_org_permission.id ) session.add_all([role_permission_link1, role_permission_link2]) session.commit() - # Delete the role + # Verify that RolePermissionLinks exist before deletion + role_permissions = session.exec(select(RolePermissionLink)).all() + assert len(role_permissions) == 2 + + # Delete the role (this will cascade delete the permission links) session.delete(role) session.commit() - # Verify that permissions still exist + # Verify that all permissions still exist remaining_permissions = session.exec(select(Permission)).all() - assert len(remaining_permissions) == 2 - assert permission1 in remaining_permissions - assert permission2 in remaining_permissions + assert len(remaining_permissions) == len(ValidPermissions) + assert delete_org_permission in remaining_permissions + assert edit_org_permission in remaining_permissions - # Verify that RolePermissionLinks are deleted + # Verify that RolePermissionLinks were cascade deleted remaining_role_permissions = session.exec(select(RolePermissionLink)).all() assert len(remaining_role_permissions) == 0 diff --git a/utils/models.py b/utils/models.py index 107e7fa..786c33b 100644 --- a/utils/models.py +++ b/utils/models.py @@ -3,7 +3,7 @@ from datetime import datetime, UTC, timedelta from typing import Optional, List from sqlmodel import SQLModel, Field, Relationship -from sqlalchemy import Column, Enum as SQLAlchemyEnum +from sqlalchemy import Column, Enum as SQLAlchemyEnum, ForeignKey def utc_time(): @@ -26,16 +26,21 @@ class ValidPermissions(Enum): class UserOrganizationLink(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="user.id", ondelete="CASCADE") - organization_id: int = Field( - foreign_key="organization.id", ondelete="CASCADE") + user_id: int = Field(foreign_key="user.id") + organization_id: int = Field(foreign_key="organization.id") role_id: int = Field(foreign_key="role.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - user: "User" = Relationship(back_populates="organization_links") - organization: "Organization" = Relationship(back_populates="user_links") - role: "Role" = Relationship(back_populates="user_links") + user: "User" = Relationship( + back_populates="organization_links" + ) + organization: "Organization" = Relationship( + back_populates="user_links" + ) + role: "Role" = Relationship( + back_populates="user_links" + ) class RolePermissionLink(SQLModel, table=True): @@ -46,6 +51,23 @@ class RolePermissionLink(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_time) +class Permission(SQLModel, table=True): + """ + Represents a permission that can be assigned to a role. Should not be + modified unless the application logic and ValidPermissions enum change. + """ + id: Optional[int] = Field(default=None, primary_key=True) + name: ValidPermissions = Field( + sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False))) + created_at: datetime = Field(default_factory=utc_time) + updated_at: datetime = Field(default_factory=utc_time) + + roles: List["Role"] = Relationship( + back_populates="permissions", + link_model=RolePermissionLink + ) + + class Organization(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str @@ -53,10 +75,11 @@ class Organization(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_time) user_links: List[UserOrganizationLink] = Relationship( - back_populates="organization") - users: List["User"] = Relationship( - back_populates="organizations", - link_model=UserOrganizationLink + back_populates="organization", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan", + "passive_deletes": True + } ) roles: List["Role"] = Relationship(back_populates="organization") @@ -87,25 +110,9 @@ class Role(SQLModel, table=True): ) -class Permission(SQLModel, table=True): - """ - Represents a permission that can be assigned to a role. - """ - id: Optional[int] = Field(default=None, primary_key=True) - name: ValidPermissions = Field( - sa_column=Column(SQLAlchemyEnum(ValidPermissions, create_type=False))) - created_at: datetime = Field(default_factory=utc_time) - updated_at: datetime = Field(default_factory=utc_time) - - roles: List["Role"] = Relationship( - back_populates="permissions", - link_model=RolePermissionLink - ) - - class PasswordResetToken(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - user_id: Optional[int] = Field(foreign_key="user.id", ondelete="CASCADE") + user_id: Optional[int] = Field(foreign_key="user.id") token: str = Field(default_factory=lambda: str( uuid4()), index=True, unique=True) expires_at: datetime = Field( @@ -116,6 +123,7 @@ class PasswordResetToken(SQLModel, table=True): back_populates="password_reset_tokens") +# TODO: Prevent deleting a user who is sole owner of an organization class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str @@ -126,11 +134,16 @@ class User(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_time) organization_links: List[UserOrganizationLink] = Relationship( - back_populates="user" - ) - organizations: List["Organization"] = Relationship( - back_populates="users", - link_model=UserOrganizationLink + back_populates="user", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan", + "passive_deletes": True + } ) password_reset_tokens: List["PasswordResetToken"] = Relationship( - back_populates="user") + back_populates="user", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan", + "passive_deletes": True + } + ) From a373df7f7cdd55000792ffc8376cb7c549843025 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Fri, 29 Nov 2024 20:26:36 +0000 Subject: [PATCH 42/73] Eagerly load roles, orgs, and permissions with user in endpoints that need them --- main.py | 24 +++--- routers/organization.py | 84 ++++++++------------- utils/auth.py | 23 +++++- utils/models.py | 55 ++++++++------ utils/role_org.py | 160 ---------------------------------------- 5 files changed, 95 insertions(+), 251 deletions(-) delete mode 100644 utils/role_org.py diff --git a/main.py b/main.py index 392d251..131494f 100644 --- a/main.py +++ b/main.py @@ -8,10 +8,9 @@ from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException from sqlmodel import Session from routers import authentication, organization, role, user -from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError +from utils.auth import get_authenticated_user, get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError from utils.models import User from utils.db import get_session, set_up_db -from utils.role_org import get_user_organizations, get_organization_roles logger = logging.getLogger("uvicorn.error") logger.setLevel(logging.DEBUG) @@ -236,28 +235,27 @@ async def common_authenticated_parameters( return {"request": request, "user": user, "error_message": error_message} +async def common_authenticated_parameters_with_organizations( + request: Request, + user: User = Depends(get_user_with_relations), + error_message: Optional[str] = None +) -> dict: + return {"request": request, "user": user, "error_message": error_message} + + # Redirect to home if user is not authenticated @app.get("/dashboard") async def read_dashboard( params: dict = Depends(common_authenticated_parameters) ): - if not params["user"]: - return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) return templates.TemplateResponse(params["request"], "dashboard/index.html", params) @app.get("/profile") async def read_profile( - params: dict = Depends(common_authenticated_parameters), - session: Session = Depends(get_session) + params: dict = Depends(common_authenticated_parameters_with_organizations) ): - if not params["user"]: - return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) - - # Get user's organizations - params["organizations"] = get_user_organizations( - params["user"].id, session) - + params["organizations"] = params["user"].organizations return templates.TemplateResponse(params["request"], "users/profile.html", params) diff --git a/routers/organization.py b/routers/organization.py index d030bb9..9a7a1b2 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -4,11 +4,9 @@ from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session -from utils.auth import get_authenticated_user -from utils.models import Organization, User, Role, Permission, UserOrganizationLink, ValidPermissions, utc_time +from utils.auth import get_authenticated_user, get_user_with_relations +from utils.models import Organization, User, Role, utc_time, default_roles from datetime import datetime -from sqlalchemy import and_ -from utils.role_org import get_organization, check_user_permission logger = getLogger("uvicorn.error") @@ -23,14 +21,6 @@ def __init__(self): ) -class OrganizationExistsError(HTTPException): - def __init__(self): - super().__init__( - status_code=400, - detail="Organization already exists" - ) - - class OrganizationNotFoundError(HTTPException): def __init__(self): super().__init__( @@ -109,50 +99,35 @@ def create_organization( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: + # Check if organization already exists db_org = session.exec(select(Organization).where( Organization.name == org.name)).first() if db_org: - raise OrganizationExistsError() + raise OrganizationNameTakenError() + # Create organization first db_org = Organization(name=org.name) session.add(db_org) - session.commit() - session.refresh(db_org) + # This gets us the org ID without committing + session.flush() - # Create default roles - default_role_names = ["Owner", "Administrator", "Member"] - default_roles = [] - for role_name in default_role_names: - role = Role(name=role_name, organization_id=db_org.id) - session.add(role) - default_roles.append(role) - session.commit() + # Create default roles with organization_id + initial_roles = [ + Role(name=name, organization_id=db_org.id) + for name in default_roles + ] + session.add_all(initial_roles) + session.flush() - owner_role = session.exec( - select(Role).where( - and_( - Role.organization_id == db_org.id, - Role.name == "Owner" - ) - ) - ).first() + # Get owner role for user assignment + owner_role = next(role for role in db_org.roles if role.name == "Owner") - if not owner_role: - owner_role = Role( - name="Owner", - organization_id=db_org.id - ) - session.add(owner_role) - session.commit() - session.refresh(owner_role) - - user_org_link = UserOrganizationLink( - user_id=user.id, - organization_id=db_org.id, - role_id=owner_role.id - ) - session.add(user_org_link) + # Assign user to owner role + user.roles.append(owner_role) + + # Commit changes session.commit() + session.refresh(db_org) return RedirectResponse(url=f"/profile", status_code=303) @@ -160,13 +135,13 @@ def create_organization( @router.put("/{org_id}", response_class=RedirectResponse) def update_organization( org: OrganizationUpdate = Depends(OrganizationUpdate.as_form), - user: User = Depends(get_authenticated_user), + user: User = Depends(get_user_with_relations), session: Session = Depends(get_session) ) -> RedirectResponse: # This will raise appropriate exceptions if org doesn't exist or user lacks access - organization = get_organization(org.id, user.id, session) + organization: Organization = user.organizations.get(org.id) - if not check_user_permission(user.id, org.id, ValidPermissions.EDIT_ORGANIZATION, session): + if not organization or not any(role.permissions.EDIT_ORGANIZATION for role in organization.roles): raise InsufficientPermissionsError() # Check if new name already exists for another organization @@ -178,11 +153,11 @@ def update_organization( if existing_org: raise OrganizationNameTakenError() + # Update organization name organization.name = org.name organization.updated_at = utc_time() session.add(organization) session.commit() - session.refresh(organization) return RedirectResponse(url=f"/profile", status_code=303) @@ -190,12 +165,15 @@ def update_organization( @router.delete("/{org_id}", response_class=RedirectResponse) def delete_organization( org_id: int, - user: User = Depends(get_authenticated_user), + user: User = Depends(get_user_with_relations), session: Session = Depends(get_session) ) -> RedirectResponse: - # This will raise appropriate exceptions if org doesn't exist or user lacks access - organization = get_organization(org_id, user.id, session) + # Check if user has permission to delete organization + organization: Organization = user.organizations.get(org_id) + if not organization or not any(role.permissions.DELETE_ORGANIZATION for role in organization.roles): + raise InsufficientPermissionsError() + # Delete organization session.delete(organization) session.commit() diff --git a/utils/auth.py b/utils/auth.py index 5793c3e..b99c7cf 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -8,12 +8,13 @@ from dotenv import load_dotenv from pydantic import field_validator, ValidationInfo from sqlmodel import Session, select +from sqlalchemy.orm import selectinload from bcrypt import gensalt, hashpw, checkpw from datetime import UTC, datetime, timedelta from typing import Optional from fastapi import Depends, Cookie, HTTPException, status from utils.db import get_session -from utils.models import User, PasswordResetToken +from utils.models import User, Role, PasswordResetToken load_dotenv() logger = logging.getLogger("uvicorn.error") @@ -339,3 +340,23 @@ def get_user_from_reset_token(email: str, token: str, session: Session) -> tuple user, reset_token = result return user, reset_token + + +def get_user_with_relations( + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session), +) -> User: + """ + Returns an authenticated user with fully loaded role and organization relationships. + """ + # Refresh the user instance with eagerly loaded relationships + eager_user = session.exec( + select(User) + .where(User.id == user.id) + .options( + selectinload(User.roles).selectinload(Role.organization), + selectinload(User.roles).selectinload(Role.permissions) + ) + ).one() + + return eager_user diff --git a/utils/models.py b/utils/models.py index 786c33b..9c80d78 100644 --- a/utils/models.py +++ b/utils/models.py @@ -3,7 +3,7 @@ from datetime import datetime, UTC, timedelta from typing import Optional, List from sqlmodel import SQLModel, Field, Relationship -from sqlalchemy import Column, Enum as SQLAlchemyEnum, ForeignKey +from sqlalchemy import Column, Enum as SQLAlchemyEnum def utc_time(): @@ -13,6 +13,8 @@ def utc_time(): default_roles = ["Owner", "Administrator", "Member"] +# TODO: User with permission to create/edit roles can only assign permissions +# they themselves have. class ValidPermissions(Enum): DELETE_ORGANIZATION = "Delete Organization" EDIT_ORGANIZATION = "Edit Organization" @@ -24,24 +26,17 @@ class ValidPermissions(Enum): EDIT_ROLE = "Edit Role" -class UserOrganizationLink(SQLModel, table=True): +class UserRoleLink(SQLModel, table=True): + """ + Associates users with roles. This creates a many-to-many relationship + between users and roles. + """ id: Optional[int] = Field(default=None, primary_key=True) user_id: int = Field(foreign_key="user.id") - organization_id: int = Field(foreign_key="organization.id") role_id: int = Field(foreign_key="role.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - user: "User" = Relationship( - back_populates="organization_links" - ) - organization: "Organization" = Relationship( - back_populates="user_links" - ) - role: "Role" = Relationship( - back_populates="user_links" - ) - class RolePermissionLink(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -74,14 +69,20 @@ class Organization(SQLModel, table=True): created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - user_links: List[UserOrganizationLink] = Relationship( + roles: List["Role"] = Relationship( back_populates="organization", sa_relationship_kwargs={ "cascade": "all, delete-orphan", "passive_deletes": True } ) - roles: List["Role"] = Relationship(back_populates="organization") + + @property + def users(self) -> List["User"]: + """ + Returns all users in the organization via their roles. + """ + return [role.users for role in self.roles] class Role(SQLModel, table=True): @@ -101,9 +102,11 @@ class Role(SQLModel, table=True): created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - organization: "Organization" = Relationship(back_populates="roles") - user_links: List[UserOrganizationLink] = Relationship( - back_populates="role") + organization: Organization = Relationship(back_populates="roles") + users: List["User"] = Relationship( + back_populates="roles", + link_model=UserRoleLink + ) permissions: List["Permission"] = Relationship( back_populates="roles", link_model=RolePermissionLink @@ -133,12 +136,9 @@ class User(SQLModel, table=True): created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - organization_links: List[UserOrganizationLink] = Relationship( - back_populates="user", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan", - "passive_deletes": True - } + roles: List[Role] = Relationship( + back_populates="users", + link_model=UserRoleLink ) password_reset_tokens: List["PasswordResetToken"] = Relationship( back_populates="user", @@ -147,3 +147,10 @@ class User(SQLModel, table=True): "passive_deletes": True } ) + + @property + def organizations(self) -> List[Organization]: + """ + Returns all organizations the user belongs to via their roles. + """ + return [role.organization for role in self.roles] diff --git a/utils/role_org.py b/utils/role_org.py deleted file mode 100644 index adb61b5..0000000 --- a/utils/role_org.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import List -from sqlmodel import Session, select -from sqlalchemy import and_, or_ -from fastapi import HTTPException -from utils.models import Organization, Role, UserOrganizationLink, RolePermissionLink, Permission, ValidPermissions - - -class OrganizationNotFoundError(HTTPException): - def __init__(self): - super().__init__( - status_code=404, - detail="Organization not found" - ) - - -class InsufficientPermissionsError(HTTPException): - def __init__(self): - super().__init__( - status_code=403, - detail="You don't have permission to perform this action" - ) - - -def get_user_organizations( - user_id: int, - session: Session -) -> List[Organization]: - """ - Retrieve all organizations a user is a member of. - - Args: - user_id: ID of the user - session: Database session - - Returns: - List of Organization objects the user belongs to - """ - query = ( - select(Organization) - .join(UserOrganizationLink) - .where(UserOrganizationLink.user_id == user_id) - ) - - return list(session.exec(query)) - - -def get_organization( - org_id: int, - user_id: int, - session: Session, -) -> Organization: - """ - Retrieve a specific organization if the user is a member. - - Args: - org_id: ID of the organization - user_id: ID of the user - session: Database session - - Returns: - Organization object - - Raises: - OrganizationNotFoundError: If organization doesn't exist - InsufficientPermissionsError: If user is not a member - """ - # Check if user is a member of the organization - user_org = session.exec( - select(UserOrganizationLink).where( - and_( - UserOrganizationLink.user_id == user_id, - UserOrganizationLink.organization_id == org_id - ) - ) - ).first() - - if not user_org: - raise InsufficientPermissionsError() - - db_org = session.get(Organization, org_id) - if not db_org: - raise OrganizationNotFoundError() - - return db_org - - -def check_user_permission( - user_id: int, - org_id: int, - permission: ValidPermissions, - session: Session, -) -> bool: - """ - Check if user has the specified permission for the organization. - - Args: - user_id: ID of the user - org_id: ID of the organization - permission: Permission to check - session: Database session - - Returns: - True if user has permission, False otherwise - """ - # Get user's role in the organization - user_org = session.exec( - select(UserOrganizationLink).where( - and_( - UserOrganizationLink.user_id == user_id, - UserOrganizationLink.organization_id == org_id - ) - ) - ).first() - - if not user_org: - return False - - # Get permission ID - permission_record = session.exec( - select(Permission).where(Permission.name == permission) - ).first() - - if not permission_record: - return False - - # Check if role has the permission - role_permission = session.exec( - select(RolePermissionLink).where( - and_( - RolePermissionLink.role_id == user_org.role_id, - RolePermissionLink.permission_id == permission_record.id - ) - ) - ).first() - - return bool(role_permission) - - -def get_organization_roles( - organization_id: int, - session: Session -) -> List[Role]: - """ - Retrieve all roles for an organization. - - Args: - organization_id: ID of the organization - session: Database session - - Returns: - List of Role objects with their associated permissions - """ - query = select(Role).where( - or_( - Role.organization_id == organization_id, - Role.organization_id == None - ) - ) - - return list(session.exec(query)) From 747352b798c293ee7e5fc82b405ae687c3008be0 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Fri, 29 Nov 2024 20:59:14 +0000 Subject: [PATCH 43/73] Org creation now works correctly! :-O --- main.py | 11 +---------- routers/authentication.py | 1 + routers/organization.py | 6 +++--- templates/authentication/register.html | 1 + templates/components/organizations.html | 2 +- templates/users/profile.html | 6 +++--- 6 files changed, 10 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index 131494f..8dd1cbc 100644 --- a/main.py +++ b/main.py @@ -228,14 +228,6 @@ async def read_reset_password( # Define a dependency for common parameters async def common_authenticated_parameters( - request: Request, - user: User = Depends(get_authenticated_user), - error_message: Optional[str] = None, -) -> dict: - return {"request": request, "user": user, "error_message": error_message} - - -async def common_authenticated_parameters_with_organizations( request: Request, user: User = Depends(get_user_with_relations), error_message: Optional[str] = None @@ -253,9 +245,8 @@ async def read_dashboard( @app.get("/profile") async def read_profile( - params: dict = Depends(common_authenticated_parameters_with_organizations) + params: dict = Depends(common_authenticated_parameters) ): - params["organizations"] = params["user"].organizations return templates.TemplateResponse(params["request"], "users/profile.html", params) diff --git a/routers/authentication.py b/routers/authentication.py index d487575..78ae9b0 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -119,6 +119,7 @@ class UserRead(BaseModel): # -- Routes -- +# TODO: Use custom error message in the case where the user is already registered @router.post("/register", response_class=RedirectResponse) async def register( user: UserRegister = Depends(UserRegister.as_form), diff --git a/routers/organization.py b/routers/organization.py index 9a7a1b2..b4c5c69 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -93,7 +93,7 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): # -- Routes -- -@router.post("/", response_class=RedirectResponse) +@router.post("/create", name="create_organization", response_class=RedirectResponse) def create_organization( org: OrganizationCreate = Depends(OrganizationCreate.as_form), user: User = Depends(get_authenticated_user), @@ -132,7 +132,7 @@ def create_organization( return RedirectResponse(url=f"/profile", status_code=303) -@router.put("/{org_id}", response_class=RedirectResponse) +@router.post("/update/{org_id}", name="update_organization", response_class=RedirectResponse) def update_organization( org: OrganizationUpdate = Depends(OrganizationUpdate.as_form), user: User = Depends(get_user_with_relations), @@ -162,7 +162,7 @@ def update_organization( return RedirectResponse(url=f"/profile", status_code=303) -@router.delete("/{org_id}", response_class=RedirectResponse) +@router.post("/delete/{org_id}", name="delete_organization", response_class=RedirectResponse) def delete_organization( org_id: int, user: User = Depends(get_user_with_relations), diff --git a/templates/authentication/register.html b/templates/authentication/register.html index ceb8aac..e508fd1 100644 --- a/templates/authentication/register.html +++ b/templates/authentication/register.html @@ -24,6 +24,7 @@
+
-
+
User Profile Change Password
- +

To change your password, please confirm your email. A password reset link will be sent to your email address.

@@ -68,8 +68,8 @@

User Profile

- - {{ render_organizations(organizations) }} + + {{ render_organizations(user.roles|map(attribute='organization')|list) }}
From 3f1334560cff027187b0257a8a785c8547d783f2 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sat, 30 Nov 2024 01:41:01 +0000 Subject: [PATCH 44/73] Added page for managing an organization --- main.py | 25 +++++++-- routers/organization.py | 8 +-- templates/users/organization.html | 90 +++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 templates/users/organization.html diff --git a/main.py b/main.py index 8dd1cbc..1605440 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,8 @@ from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException from sqlmodel import Session from routers import authentication, organization, role, user -from utils.auth import get_authenticated_user, get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError -from utils.models import User +from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError +from utils.models import User, Organization from utils.db import get_session, set_up_db logger = logging.getLogger("uvicorn.error") @@ -19,8 +19,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): # Optional startup logic - # TODO: Set drop=False in production - set_up_db(drop=True) + set_up_db() yield # Optional shutdown logic @@ -250,6 +249,24 @@ async def read_profile( return templates.TemplateResponse(params["request"], "users/profile.html", params) +@app.get("/organizations/{org_id}") +async def read_organization( + org_id: int, + params: dict = Depends(common_authenticated_parameters) +): + # Get the organization only if the user is a member of it + organization: Organization = params["user"].organizations.get(org_id) + if not organization: + raise organization.OrganizationNotFoundError() + + # Eagerly load roles and users + organization.roles + organization.users + params["organization"] = organization + + return templates.TemplateResponse(params["request"], "users/organization.html", params) + + # -- Include Routers -- diff --git a/routers/organization.py b/routers/organization.py index b4c5c69..7817955 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -1,6 +1,6 @@ from logging import getLogger from fastapi import APIRouter, Depends, HTTPException, Form -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, HTMLResponse from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session @@ -93,7 +93,7 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): # -- Routes -- -@router.post("/create", name="create_organization", response_class=RedirectResponse) +@router.post("/create", response_class=RedirectResponse) def create_organization( org: OrganizationCreate = Depends(OrganizationCreate.as_form), user: User = Depends(get_authenticated_user), @@ -129,7 +129,7 @@ def create_organization( session.commit() session.refresh(db_org) - return RedirectResponse(url=f"/profile", status_code=303) + return RedirectResponse(url=f"/organizations/{db_org.id}", status_code=303) @router.post("/update/{org_id}", name="update_organization", response_class=RedirectResponse) @@ -162,7 +162,7 @@ def update_organization( return RedirectResponse(url=f"/profile", status_code=303) -@router.post("/delete/{org_id}", name="delete_organization", response_class=RedirectResponse) +@router.post("/delete/{org_id}", response_class=RedirectResponse) def delete_organization( org_id: int, user: User = Depends(get_user_with_relations), diff --git a/templates/users/organization.html b/templates/users/organization.html new file mode 100644 index 0000000..2e75fa9 --- /dev/null +++ b/templates/users/organization.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% from 'components/silhouette.html' import render_silhouette %} + +{% block title %}{{ organization.name }}{% endblock %} + +{% block content %} +
+

{{ organization.name }}

+ + +
+
+ Roles +
+
+
+ + + + + + + + + + {% for role in organization.roles %} + + + + + + {% endfor %} + +
Role NameMembersPermissions
{{ role.name }}{{ role.users|length }} +
    + {% for permission in role.permissions %} +
  • {{ permission.name.value }}
  • + {% endfor %} +
+
+
+
+
+ + +
+
+ Members +
+
+
+ + + + + + + + + + + {% for role in organization.roles %} + {% for user in role.users %} + + + + + + + {% endfor %} + {% endfor %} + +
NameEmailRoles
+ {% if user.avatar_url %} + User Avatar + {% else %} + {{ render_silhouette(width=40, height=40) }} + {% endif %} + {{ user.name }}{{ user.email }} + {% for user_role in user.roles %} + {% if user_role.organization_id == organization.id %} + {{ user_role.name }} + {% endif %} + {% endfor %} +
+
+
+
+
+{% endblock %} From 2587dcf165dfb5e395cfed43a87d2d060260fdde Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sat, 30 Nov 2024 23:13:36 +0000 Subject: [PATCH 45/73] Tests of cascade delete behaviors pass --- docs/customization.qmd | 31 +++++++++- tests/conftest.py | 14 ++--- tests/test_models.py | 137 ++++++++++++++++++++++++++++++++++++++--- utils/models.py | 30 ++++----- 4 files changed, 178 insertions(+), 34 deletions(-) diff --git a/docs/customization.qmd b/docs/customization.qmd index 6bc663d..150238b 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -274,9 +274,9 @@ graph.write_png('static/schema.png') ![Database Schema](static/schema.png) -#### Database operations +#### Database helpers -Database operations are handled by helper functions in `utils/db.py`. Key functions include: +Database operations are facilitated by helper functions in `utils/db.py`. Key functions include: - `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) - `get_connection_url()`: Creates a database connection URL from environment variables in `.env` @@ -292,3 +292,30 @@ async def get_users(session: Session = Depends(get_session)): ``` The session automatically handles transaction management, ensuring that database operations are atomic and consistent. + +#### Cascade deletes + +Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: + +```python +sa_relationship_kwargs={ + "cascade": "all, delete-orphan" +} +``` + +This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly. + +For example, + +```python +session.exec(delete(Role)) +``` + +will not trigger the cascade delete. Instead, we need to select the role objects and then delete them: + +```python +for role in session.exec(select(Role)).all(): + session.delete(role) +``` + +This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage. diff --git a/tests/conftest.py b/tests/conftest.py index a6dbb2b..6e29a8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from sqlmodel import create_engine, Session, select from fastapi.testclient import TestClient from utils.db import get_connection_url, set_up_db, tear_down_db, get_session -from utils.models import User, PasswordResetToken, Organization +from utils.models import User, PasswordResetToken, Organization, Role from utils.auth import get_password_hash, create_access_token, create_refresh_token from main import app @@ -47,15 +47,9 @@ def clean_db(session: Session): """ Cleans up the database tables before each test. """ - # Delete all PasswordResetTokens - tokens = session.exec(select(PasswordResetToken)).all() - for token in tokens: - session.delete(token) - - # Delete all Users - users = session.exec(select(User)).all() - for user in users: - session.delete(user) + for model in (PasswordResetToken, User, Role, Organization): + for record in session.exec(select(model)).all(): + session.delete(record) session.commit() diff --git a/tests/test_models.py b/tests/test_models.py index e0894a6..9928b47 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,7 +6,11 @@ RolePermissionLink, Organization, ValidPermissions, + User, + UserRoleLink, + PasswordResetToken, ) +from datetime import timedelta, datetime, UTC def test_permissions_persist_after_role_deletion(session: Session): @@ -36,13 +40,8 @@ def test_permissions_persist_after_role_deletion(session: Session): edit_org_permission = next( p for p in all_permissions if p.name == ValidPermissions.EDIT_ORGANIZATION) - role_permission_link1 = RolePermissionLink( - role_id=role.id, permission_id=delete_org_permission.id - ) - role_permission_link2 = RolePermissionLink( - role_id=role.id, permission_id=edit_org_permission.id - ) - session.add_all([role_permission_link1, role_permission_link2]) + role.permissions.append(delete_org_permission) + role.permissions.append(edit_org_permission) session.commit() # Verify that RolePermissionLinks exist before deletion @@ -62,3 +61,127 @@ def test_permissions_persist_after_role_deletion(session: Session): # Verify that RolePermissionLinks were cascade deleted remaining_role_permissions = session.exec(select(RolePermissionLink)).all() assert len(remaining_role_permissions) == 0 + + +def test_user_organizations_property(session: Session, test_user: User, test_organization: Organization): + """ + Test that User.organizations property correctly returns all organizations + the user belongs to via their roles. + """ + # Create a role in the test organization + role = Role(name="Test Role", organization_id=test_organization.id) + session.add(role) + + # Link the user to the role + test_user.roles.append(role) + session.commit() + + # Refresh the user to ensure relationships are loaded + session.refresh(test_user) + + # Test the organizations property + assert len(test_user.organizations) == 1 + assert test_user.organizations[0].id == test_organization.id + + +def test_organization_users_property(session: Session, test_user: User, test_organization: Organization): + """ + Test that Organization.users property correctly returns all users + in the organization via their roles. + """ + # Create a role in the test organization + role = Role(name="Test Role", organization_id=test_organization.id) + session.add(role) + session.commit() + + # Link the user to the role + test_user.roles.append(role) + session.commit() + + # Refresh the organization to ensure relationships are loaded + session.refresh(test_organization) + + # Test the users property + users_list = test_organization.users + assert len(users_list) == 1 + # users_list is a list of lists due to the property implementation + assert test_user in users_list[0] + + +def test_cascade_delete_organization(session: Session, test_user: User, test_organization: Organization): + """ + Test that deleting an organization cascades properly: + - Deletes associated roles + - Deletes role-user links + - Does not delete users + """ + # Create a role in the test organization + role = Role(name="Test Role", organization_id=test_organization.id) + session.add(role) + test_user.roles.append(role) + session.commit() + + # Delete the organization + session.delete(test_organization) + session.commit() + + # Verify the role was deleted + remaining_roles = session.exec(select(Role)).all() + assert len(remaining_roles) == 0 + + # Verify the user-role link was deleted + remaining_links = session.exec(select(UserRoleLink)).all() + assert len(remaining_links) == 0 + + # Verify the user still exists + remaining_user = session.exec(select(User)).first() + assert remaining_user is not None + assert remaining_user.id == test_user.id + + +def test_password_reset_token_cascade_delete(session: Session, test_user: User): + """ + Test that password reset tokens are deleted when a user is deleted + """ + # Create reset tokens for the user + token1 = PasswordResetToken(user_id=test_user.id) + token2 = PasswordResetToken(user_id=test_user.id) + session.add(token1) + session.add(token2) + session.commit() + + # Verify tokens exist + tokens = session.exec(select(PasswordResetToken)).all() + assert len(tokens) == 2 + + # Delete the user + session.delete(test_user) + session.commit() + + # Verify tokens were cascade deleted + remaining_tokens = session.exec(select(PasswordResetToken)).all() + assert len(remaining_tokens) == 0 + + +def test_password_reset_token_is_expired(session: Session, test_user: User): + """ + Test that password reset token expiration is properly set and checked + """ + # Create an expired token + expired_token = PasswordResetToken( + user_id=test_user.id, + expires_at=datetime.now(UTC) - timedelta(hours=1) + ) + session.add(expired_token) + + # Create a valid token + valid_token = PasswordResetToken( + user_id=test_user.id, + expires_at=datetime.now(UTC) + timedelta(hours=1) + ) + session.add(valid_token) + session.commit() + + # Verify expiration states + assert expired_token.is_expired() + assert not valid_token.is_expired() diff --git a/utils/models.py b/utils/models.py index 9c80d78..f02ca45 100644 --- a/utils/models.py +++ b/utils/models.py @@ -31,19 +31,14 @@ class UserRoleLink(SQLModel, table=True): Associates users with roles. This creates a many-to-many relationship between users and roles. """ - id: Optional[int] = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="user.id") - role_id: int = Field(foreign_key="role.id") - created_at: datetime = Field(default_factory=utc_time) - updated_at: datetime = Field(default_factory=utc_time) + user_id: Optional[int] = Field(foreign_key="user.id", primary_key=True) + role_id: Optional[int] = Field(foreign_key="role.id", primary_key=True) class RolePermissionLink(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - role_id: int = Field(foreign_key="role.id") - permission_id: int = Field(foreign_key="permission.id") - created_at: datetime = Field(default_factory=utc_time) - updated_at: datetime = Field(default_factory=utc_time) + role_id: Optional[int] = Field(foreign_key="role.id", primary_key=True) + permission_id: Optional[int] = Field( + foreign_key="permission.id", primary_key=True) class Permission(SQLModel, table=True): @@ -72,8 +67,7 @@ class Organization(SQLModel, table=True): roles: List["Role"] = Relationship( back_populates="organization", sa_relationship_kwargs={ - "cascade": "all, delete-orphan", - "passive_deletes": True + "cascade": "all, delete-orphan" } ) @@ -98,7 +92,8 @@ class Role(SQLModel, table=True): """ id: Optional[int] = Field(default=None, primary_key=True) name: str - organization_id: int = Field(foreign_key="organization.id") + organization_id: int = Field( + foreign_key="organization.id") created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) @@ -125,6 +120,12 @@ class PasswordResetToken(SQLModel, table=True): user: Optional["User"] = Relationship( back_populates="password_reset_tokens") + def is_expired(self) -> bool: + """ + Check if the token has expired + """ + return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) + # TODO: Prevent deleting a user who is sole owner of an organization class User(SQLModel, table=True): @@ -143,8 +144,7 @@ class User(SQLModel, table=True): password_reset_tokens: List["PasswordResetToken"] = Relationship( back_populates="user", sa_relationship_kwargs={ - "cascade": "all, delete-orphan", - "passive_deletes": True + "cascade": "all, delete-orphan" } ) From c2d63c639712dbed416cb17f1081fa69a0022f7b Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 1 Dec 2024 17:41:30 +0000 Subject: [PATCH 46/73] Moved password hash to a separate database model, resolved some mypy type linter errors --- docs/customization.qmd | 2 +- main.py | 10 +++--- routers/authentication.py | 24 ++++++++++--- routers/organization.py | 13 ++++--- routers/user.py | 8 ++++- tests/conftest.py | 13 +++++-- tests/test_authentication.py | 17 +++++++--- tests/test_db.py | 11 ++++-- tests/test_models.py | 55 ++++++++++++++++++++++++++---- utils/db.py | 4 +-- utils/models.py | 66 ++++++++++++++++++++++++++++++------ 11 files changed, 178 insertions(+), 45 deletions(-) diff --git a/docs/customization.qmd b/docs/customization.qmd index 150238b..e43169e 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -41,7 +41,7 @@ To run the tests, use these commands: The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: ```bash -mypy +mypy . ``` We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change! diff --git a/main.py b/main.py index 1605440..82e1cc4 100644 --- a/main.py +++ b/main.py @@ -255,14 +255,14 @@ async def read_organization( params: dict = Depends(common_authenticated_parameters) ): # Get the organization only if the user is a member of it - organization: Organization = params["user"].organizations.get(org_id) - if not organization: + org: Organization = params["user"].organizations.get(org_id) + if not org: raise organization.OrganizationNotFoundError() # Eagerly load roles and users - organization.roles - organization.users - params["organization"] = organization + org.roles + org.users + params["organization"] = org return templates.TemplateResponse(params["request"], "users/organization.html", params) diff --git a/routers/authentication.py b/routers/authentication.py index 78ae9b0..0832954 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -6,7 +6,7 @@ from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr, ConfigDict from sqlmodel import Session, select -from utils.models import User +from utils.models import User, UserPassword from utils.auth import ( get_session, get_user_from_reset_token, @@ -125,15 +125,19 @@ async def register( user: UserRegister = Depends(UserRegister.as_form), session: Session = Depends(get_session), ) -> RedirectResponse: + # Check if the email is already registered db_user = session.exec(select(User).where( User.email == user.email)).first() if db_user: raise HTTPException(status_code=400, detail="Email already registered") + # Hash the password hashed_password = get_password_hash(user.password) + + # Create the user db_user = User(name=user.name, email=user.email, - hashed_password=hashed_password) + password=UserPassword(hashed_password=hashed_password)) session.add(db_user) session.commit() session.refresh(db_user) @@ -155,9 +159,11 @@ async def login( user: UserLogin = Depends(UserLogin.as_form), session: Session = Depends(get_session), ) -> RedirectResponse: + # Check if the email is registered db_user = session.exec(select(User).where( User.email == user.email)).first() - if not db_user or not verify_password(user.password, db_user.hashed_password): + + if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password): raise HTTPException(status_code=400, detail="Invalid credentials") # Create access token @@ -259,7 +265,17 @@ async def reset_password( raise HTTPException(status_code=400, detail="Invalid or expired token") # Update password and mark token as used - authorized_user.hashed_password = get_password_hash(user.new_password) + if authorized_user.password: + authorized_user.password.hashed_password = get_password_hash( + user.new_password + ) + else: + logger.warning( + "User password not found during password reset; creating new password for user") + authorized_user.password = UserPassword( + hashed_password=get_password_hash(user.new_password) + ) + reset_token.used = True session.commit() session.refresh(authorized_user) diff --git a/routers/organization.py b/routers/organization.py index 7817955..2cd10e3 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -1,11 +1,11 @@ from logging import getLogger from fastapi import APIRouter, Depends, HTTPException, Form -from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.responses import RedirectResponse from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session from utils.auth import get_authenticated_user, get_user_with_relations -from utils.models import Organization, User, Role, utc_time, default_roles +from utils.models import Organization, User, Role, utc_time, default_roles, ValidPermissions from datetime import datetime logger = getLogger("uvicorn.error") @@ -139,9 +139,11 @@ def update_organization( session: Session = Depends(get_session) ) -> RedirectResponse: # This will raise appropriate exceptions if org doesn't exist or user lacks access - organization: Organization = user.organizations.get(org.id) + organization: Organization | None = next( + (org for org in user.organizations if org.id == org.id), None) - if not organization or not any(role.permissions.EDIT_ORGANIZATION for role in organization.roles): + # Check if user has permission to edit organization + if not organization or not user.has_permission(ValidPermissions.EDIT_ORGANIZATION, organization): raise InsufficientPermissionsError() # Check if new name already exists for another organization @@ -169,7 +171,8 @@ def delete_organization( session: Session = Depends(get_session) ) -> RedirectResponse: # Check if user has permission to delete organization - organization: Organization = user.organizations.get(org_id) + organization: Organization | None = next( + (org for org in user.organizations if org.id == org_id), None) if not organization or not any(role.permissions.DELETE_ORGANIZATION for role in organization.roles): raise InsufficientPermissionsError() diff --git a/routers/user.py b/routers/user.py index 043509d..5639d79 100644 --- a/routers/user.py +++ b/routers/user.py @@ -63,9 +63,15 @@ async def delete_account( current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): + if not current_user.password: + raise HTTPException( + status_code=500, + detail="User password not found in database; please contact a system administrator" + ) + if not verify_password( user_delete_account.confirm_delete_password, - current_user.hashed_password + current_user.password.hashed_password ): raise HTTPException( status_code=400, diff --git a/tests/conftest.py b/tests/conftest.py index 6e29a8a..565a1ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,24 @@ import pytest from dotenv import load_dotenv from sqlmodel import create_engine, Session, select +from sqlalchemy import Engine from fastapi.testclient import TestClient from utils.db import get_connection_url, set_up_db, tear_down_db, get_session -from utils.models import User, PasswordResetToken, Organization, Role +from utils.models import User, PasswordResetToken, Organization, Role, UserPassword from utils.auth import get_password_hash, create_access_token, create_refresh_token from main import app load_dotenv() +# Define a custom exception for test setup errors +class SetupError(Exception): + """Exception raised for errors in the test setup process.""" + pass + + @pytest.fixture(scope="session") -def engine(): +def engine() -> Engine: """ Create a new SQLModel engine for the test database. Use an in-memory SQLite database for testing. @@ -63,7 +70,7 @@ def test_user(session: Session): user = User( name="Test User", email="test@example.com", - hashed_password=get_password_hash("Test123!@#") + password=UserPassword(hashed_password=get_password_hash("Test123!@#")) ) session.add(user) session.commit() diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 0ba2331..58bcc04 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -17,7 +17,7 @@ validate_token, generate_password_reset_url ) - +from .conftest import SetupError # --- Fixture setup --- @@ -28,7 +28,7 @@ def mock_email_response(): """ Returns a mock Email response object """ - return resend.Email(id="6229f547-f3f6-4eb8-b0dc-82c1b09121b6") + return resend.Email(id="mock_resend_id") @pytest.fixture @@ -104,7 +104,12 @@ def test_register_endpoint(unauth_client: TestClient, session: Session): User.email == "new@example.com")).first() assert user is not None assert user.name == "New User" - assert verify_password("NewPass123!@#", user.hashed_password) + + # Verify password was hashed and matches + if not user.password: + raise SetupError( + "Test setup failed; user.password is None") + assert verify_password("NewPass123!@#", user.password.hashed_password) def test_login_endpoint(unauth_client: TestClient, test_user: User): @@ -200,10 +205,14 @@ def test_password_reset_flow(unauth_client: TestClient, session: Session, test_u ) assert response.status_code == 303 + if not test_user.password: + raise SetupError( + "Test setup failed; test_user.password is None") + # Verify password was updated and token was marked as used session.refresh(test_user) session.refresh(reset_token) - assert verify_password("NewPass123!@#", test_user.hashed_password) + assert verify_password("NewPass123!@#", test_user.password.hashed_password) assert reset_token.used diff --git a/tests/test_db.py b/tests/test_db.py index fdd85cb..aa6fe79 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,5 +1,6 @@ import warnings from sqlmodel import Session, select, inspect +from sqlalchemy import Engine from utils.db import ( get_connection_url, assign_permissions_to_role, @@ -9,7 +10,7 @@ set_up_db, ) from utils.models import Role, Permission, Organization, RolePermissionLink, ValidPermissions -from sqlalchemy import Engine +from .conftest import SetupError def test_get_connection_url(): @@ -43,8 +44,12 @@ def test_create_default_roles(session: Session, test_organization: Organization) session.commit() # Create roles for test organization - roles = create_default_roles(session, test_organization.id) - session.commit() + if test_organization.id is not None: + roles = create_default_roles(session, test_organization.id) + session.commit() + else: + raise SetupError( + "Test setup failed; test_organization.id is None") # Verify roles were created assert len(roles) == 3 # Owner, Administrator, Member diff --git a/tests/test_models.py b/tests/test_models.py index 9928b47..282ef0f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,5 @@ -import pytest +from datetime import timedelta, datetime, UTC +from typing import Optional from sqlmodel import select, Session from utils.models import ( Permission, @@ -8,9 +9,9 @@ ValidPermissions, User, UserRoleLink, - PasswordResetToken, + PasswordResetToken ) -from datetime import timedelta, datetime, UTC +from .conftest import SetupError def test_permissions_persist_after_role_deletion(session: Session): @@ -102,10 +103,9 @@ def test_organization_users_property(session: Session, test_user: User, test_org session.refresh(test_organization) # Test the users property - users_list = test_organization.users + users_list: list[User] = test_organization.users assert len(users_list) == 1 - # users_list is a list of lists due to the property implementation - assert test_user in users_list[0] + assert test_user in users_list def test_cascade_delete_organization(session: Session, test_user: User, test_organization: Organization): @@ -185,3 +185,46 @@ def test_password_reset_token_is_expired(session: Session, test_user: User): # Verify expiration states assert expired_token.is_expired() assert not valid_token.is_expired() + + +def test_user_has_permission(session: Session, test_user: User, test_organization: Organization): + """ + Test that User.has_permission method correctly checks if a user has a specific + permission for a given organization. + """ + # Create a role with specific permissions in the test organization + role = Role(name="Test Role", organization_id=test_organization.id) + session.add(role) + session.commit() + session.refresh(role) + + # Assign permissions to the role + delete_org_permission: Optional[Permission] = session.exec( + select(Permission).where(Permission.name == + ValidPermissions.DELETE_ORGANIZATION) + ).first() + edit_org_permission: Optional[Permission] = session.exec( + select(Permission).where(Permission.name == + ValidPermissions.EDIT_ORGANIZATION) + ).first() + + if delete_org_permission is not None and edit_org_permission is not None: + role.permissions.append(delete_org_permission) + role.permissions.append(edit_org_permission) + else: + raise SetupError( + "Test setup failed; permission not found in database") + session.commit() + + # Link the user to the role + test_user.roles.append(role) + session.commit() + session.refresh(test_user) + + # Test the has_permission method + assert test_user.has_permission( + ValidPermissions.DELETE_ORGANIZATION, test_organization) is True + assert test_user.has_permission( + ValidPermissions.EDIT_ORGANIZATION, test_organization) is True + assert test_user.has_permission( + ValidPermissions.INVITE_USER, test_organization) is False diff --git a/utils/db.py b/utils/db.py index a38018e..6ad99e2 100644 --- a/utils/db.py +++ b/utils/db.py @@ -1,6 +1,6 @@ import os import logging -from typing import Generator +from typing import Generator, Union, Sequence from dotenv import load_dotenv from sqlalchemy.engine import URL from sqlmodel import create_engine, Session, SQLModel, select @@ -57,7 +57,7 @@ def get_session() -> Generator[Session, None, None]: yield session -def assign_permissions_to_role(session: Session, role: Role, permissions: list[Permission], check_first: bool = False) -> None: +def assign_permissions_to_role(session: Session, role: Role, permissions: Union[list[Permission], Sequence[Permission]], check_first: bool = False) -> None: """ Assigns permissions to a role in the database. diff --git a/utils/models.py b/utils/models.py index f02ca45..3f8ed1d 100644 --- a/utils/models.py +++ b/utils/models.py @@ -1,9 +1,14 @@ +from logging import getLogger, DEBUG from enum import Enum from uuid import uuid4 from datetime import datetime, UTC, timedelta from typing import Optional, List from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Enum as SQLAlchemyEnum +from sqlalchemy.orm import Mapped + +logger = getLogger("uvicorn.error") +logger.setLevel(DEBUG) def utc_time(): @@ -52,7 +57,7 @@ class Permission(SQLModel, table=True): created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - roles: List["Role"] = Relationship( + roles: Mapped[List["Role"]] = Relationship( back_populates="permissions", link_model=RolePermissionLink ) @@ -64,7 +69,7 @@ class Organization(SQLModel, table=True): created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - roles: List["Role"] = Relationship( + roles: Mapped[List["Role"]] = Relationship( back_populates="organization", sa_relationship_kwargs={ "cascade": "all, delete-orphan" @@ -76,7 +81,15 @@ def users(self) -> List["User"]: """ Returns all users in the organization via their roles. """ - return [role.users for role in self.roles] + users = [] + # Track user IDs to ensure uniqueness + user_ids = set() + for role in self.roles: + for user in role.users: + if user.id not in user_ids: + users.append(user) + user_ids.add(user.id) + return users class Role(SQLModel, table=True): @@ -97,12 +110,12 @@ class Role(SQLModel, table=True): created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - organization: Organization = Relationship(back_populates="roles") - users: List["User"] = Relationship( + organization: Mapped[Organization] = Relationship(back_populates="roles") + users: Mapped[List["User"]] = Relationship( back_populates="roles", link_model=UserRoleLink ) - permissions: List["Permission"] = Relationship( + permissions: Mapped[List["Permission"]] = Relationship( back_populates="roles", link_model=RolePermissionLink ) @@ -117,7 +130,7 @@ class PasswordResetToken(SQLModel, table=True): default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) used: bool = Field(default=False) - user: Optional["User"] = Relationship( + user: Mapped[Optional["User"]] = Relationship( back_populates="password_reset_tokens") def is_expired(self) -> bool: @@ -127,30 +140,61 @@ def is_expired(self) -> bool: return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) +class UserPassword(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: Optional[int] = Field(foreign_key="user.id", unique=True) + hashed_password: str + + user: Mapped[Optional["User"]] = Relationship( + back_populates="password", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan", + "single_parent": True + } + ) + + # TODO: Prevent deleting a user who is sole owner of an organization class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str email: str = Field(index=True, unique=True) - hashed_password: str avatar_url: Optional[str] = None created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) - roles: List[Role] = Relationship( + roles: Mapped[List[Role]] = Relationship( back_populates="users", link_model=UserRoleLink ) - password_reset_tokens: List["PasswordResetToken"] = Relationship( + password_reset_tokens: Mapped[List["PasswordResetToken"]] = Relationship( back_populates="user", sa_relationship_kwargs={ "cascade": "all, delete-orphan" } ) + password: Mapped[Optional[UserPassword]] = Relationship( + back_populates="user" + ) @property def organizations(self) -> List[Organization]: """ Returns all organizations the user belongs to via their roles. """ - return [role.organization for role in self.roles] + organizations = [] + organization_ids = set() + for role in self.roles: + if role.organization_id not in organization_ids: + organizations.append(role.organization) + organization_ids.add(role.organization_id) + return organizations + + def has_permission(self, permission: ValidPermissions, organization: Organization) -> bool: + """ + Check if the user has a specific permission for a given organization. + """ + for role in self.roles: + if role.organization_id == organization.id: + return permission in [perm.name for perm in role.permissions] + return False From 6b48566380ec14e6c80f0b44faa93a61a25b6ecd Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 1 Dec 2024 21:48:01 +0000 Subject: [PATCH 47/73] Massively imporved/fixed role.py --- routers/organization.py | 16 ++-- routers/role.py | 195 ++++++++++++++++++++++++++++------------ utils/auth.py | 45 ++++++---- utils/db.py | 8 +- utils/models.py | 40 +++++++-- 5 files changed, 216 insertions(+), 88 deletions(-) diff --git a/routers/organization.py b/routers/organization.py index 2cd10e3..daa8504 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select from utils.db import get_session -from utils.auth import get_authenticated_user, get_user_with_relations +from utils.auth import get_authenticated_user, get_user_with_relations, InsufficientPermissionsError from utils.models import Organization, User, Role, utc_time, default_roles, ValidPermissions from datetime import datetime @@ -37,14 +37,6 @@ def __init__(self): ) -class InsufficientPermissionsError(HTTPException): - def __init__(self): - super().__init__( - status_code=403, - detail="You don't have permission to perform this action" - ) - - router = APIRouter(prefix="/organizations", tags=["organizations"]) @@ -173,7 +165,11 @@ def delete_organization( # Check if user has permission to delete organization organization: Organization | None = next( (org for org in user.organizations if org.id == org_id), None) - if not organization or not any(role.permissions.DELETE_ORGANIZATION for role in organization.roles): + if not organization or not any( + p.name == ValidPermissions.DELETE_ORGANIZATION + for role in organization.roles + for p in role.permissions + ): raise InsufficientPermissionsError() # Delete organization diff --git a/routers/role.py b/routers/role.py index 9cd798f..32bf9d7 100644 --- a/routers/role.py +++ b/routers/role.py @@ -1,13 +1,15 @@ -from typing import List -from datetime import datetime +# TODO: User with permission to create/edit roles can only assign permissions +# they themselves have. +from typing import List, Sequence, Optional from logging import getLogger from fastapi import APIRouter, Depends, Form, HTTPException from fastapi.responses import RedirectResponse from pydantic import BaseModel, ConfigDict, field_validator -from sqlmodel import Session, select +from sqlmodel import Session, select, col +from sqlalchemy.orm import selectinload from utils.db import get_session -from utils.auth import get_authenticated_user -from utils.models import Role, RolePermissionLink, ValidPermissions, utc_time, User +from utils.auth import get_authenticated_user, InsufficientPermissionsError +from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError logger = getLogger("uvicorn.error") @@ -17,6 +19,16 @@ # -- Custom Exceptions -- +class InvalidPermissionError(HTTPException): + """Raised when a user attempts to assign an invalid permission to a role""" + + def __init__(self, permission: ValidPermissions): + super().__init__( + status_code=400, + detail=f"Invalid permission: {permission}" + ) + + class RoleAlreadyExistsError(HTTPException): """Raised when attempting to create a role with a name that already exists""" @@ -31,53 +43,46 @@ def __init__(self): super().__init__(status_code=404, detail="Role not found") +class RoleHasUsersError(HTTPException): + """Raised when a requested role to be deleted has users""" + + def __init__(self): + super().__init__( + status_code=400, + detail="Role cannot be deleted until users with that role are reassigned" + ) + + # -- Server Request Models -- class RoleCreate(BaseModel): model_config = ConfigDict(from_attributes=True) name: str + organization_id: int permissions: List[ValidPermissions] - @field_validator("name") - @classmethod - def validate_unique_name(cls, name: str, info): - # Note: This requires passing session as a dependency to as_form - session = info.context.get("session") - if session and session.exec(select(Role).where(Role.name == name)).first(): - raise RoleAlreadyExistsError() - return name - @classmethod async def as_form( cls, name: str = Form(...), - permissions: List[ValidPermissions] = Form(...), - session: Session = Depends(get_session) + organization_id: int = Form(...), + permissions: List[ValidPermissions] = Form(...) ): # Pass session to validator context return cls( name=name, - permissions=permissions, - context={"session": session} + organization_id=organization_id, + permissions=permissions ) -class RoleRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - created_at: datetime - updated_at: datetime - permissions: List[ValidPermissions] - - class RoleUpdate(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str + organization_id: int permissions: List[ValidPermissions] @field_validator("id") @@ -95,17 +100,32 @@ async def as_form( cls, id: int = Form(...), name: str = Form(...), - permissions: List[ValidPermissions] = Form(...), - session: Session = Depends(get_session) + organization_id: int = Form(...), + permissions: List[ValidPermissions] = Form(...) ): return cls( id=id, name=name, - permissions=permissions, - context={"session": session} + organization_id=organization_id, + permissions=permissions ) +class RoleDelete(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + organization_id: int + + @classmethod + async def as_form( + cls, + id: int = Form(...), + organization_id: int = Form(...) + ): + return cls(id=id, organization_id=organization_id) + + # -- Routes -- @@ -115,19 +135,34 @@ def create_role( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - # Create role and permissions in a single transaction + # Check that the user-selected role name is unique for the organization + if session.exec( + select(Role).where( + Role.name == role.name, + Role.organization_id == role.organization_id + ) + ).first(): + raise RoleAlreadyExistsError() + + # Check that the user is authorized to create roles in the organization + if not user.has_permission(ValidPermissions.CREATE_ROLE, role.organization_id): + raise InsufficientPermissionsError() + + # Create role db_role = Role( name=role.name, - organization_id=user.organization_id # Add organization ID to role + organization_id=role.organization_id ) + session.add(db_role) - # Create RolePermissionLink objects and associate them with the role - db_role.permissions = [ - RolePermissionLink(permission_id=permission.name) - for permission in role.permissions - ] + # Select Permission records corresponding to the user-selected permissions + # and associate them with the newly created role + permissions: Sequence[Permission] = session.exec( + select(Permission).where(col(Permission.name).in_(role.permissions)) + ).all() + db_role.permissions.extend(permissions) - session.add(db_role) + # Commit transaction session.commit() return RedirectResponse(url="/profile", status_code=303) @@ -139,39 +174,85 @@ def update_role( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - db_role: Role | None = session.get(Role, role.id) - role_data = role.model_dump(exclude_unset=True) - for key, value in role_data.items(): - setattr(db_role, key, value) - db_role.updated_at = utc_time() - session.add(db_role) - session.commit() + # Check that the user is authorized to update the role + if not user.has_permission(ValidPermissions.EDIT_ROLE, role.organization_id): + raise InsufficientPermissionsError() + + # Select db_role to update, along with its permissions, by ID + db_role: Optional[Role] = session.exec( + select(Role).where(Role.id == role.id).options( + selectinload(Role.permissions)) + ).first() + + if not db_role: + raise RoleNotFoundError() - # Correctly delete RolePermissionLinks for the role - session.delete(RolePermissionLink.role_id == role.id) + # If any user-selected permissions are not valid, raise an error + for permission in role.permissions: + if permission not in ValidPermissions: + raise InvalidPermissionError(permission) + # Add any user-selected permissions that are not already associated with the role for permission in role.permissions: - db_role_permission_link = RolePermissionLink( - role_id=db_role.id, - permission_id=permission.name + if permission not in [p.name for p in db_role.permissions]: + db_permission: Optional[Permission] = session.exec( + select(Permission).where(Permission.name == permission) + ).first() + if db_permission: + db_role.permissions.append(db_permission) + else: + raise DataIntegrityError(resource=f"Permission: {permission}") + + # Remove any permissions that are not user-selected + for db_permission in db_role.permissions: + if db_permission.name not in role.permissions: + db_role.permissions.remove(db_permission) + + # Check that no existing organization role has the same name but a different ID + if session.exec( + select(Role).where( + Role.name == role.name, + Role.organization_id == role.organization_id, + Role.id != role.id ) - session.add(db_role_permission_link) + ).first(): + raise RoleAlreadyExistsError() + + # Update role name and updated_at timestamp + db_role.name = role.name + db_role.updated_at = utc_time() session.commit() session.refresh(db_role) return RedirectResponse(url="/profile", status_code=303) -# TODO: Reject role deletion if anyone in the organization has that role @router.delete("/{role_id}", response_class=RedirectResponse) def delete_role( - role_id: int, + role: RoleDelete = Depends(RoleDelete.as_form), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: - db_role = session.get(Role, role_id) + # Check that the user is authorized to delete the role + if not user.has_permission(ValidPermissions.DELETE_ROLE, role.organization_id): + raise InsufficientPermissionsError() + + # Select the role to delete by ID, along with its users + db_role: Role | None = session.exec( + select(Role).where(Role.id == role.id).options( + selectinload(Role.users) + ) + ).first() + if not db_role: - raise HTTPException(status_code=404, detail="Role not found") + raise RoleNotFoundError() + + # Check that no users have the role + if db_role.users: + raise RoleHasUsersError() + + # Delete the role session.delete(db_role) session.commit() + return RedirectResponse(url="/profile", status_code=303) diff --git a/utils/auth.py b/utils/auth.py index b99c7cf..e7de8c5 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -20,7 +20,8 @@ logger = logging.getLogger("uvicorn.error") -# --- AUTH --- +# --- Constants --- + SECRET_KEY = os.getenv("SECRET_KEY") ALGORITHM = "HS256" @@ -28,12 +29,15 @@ REFRESH_TOKEN_EXPIRE_DAYS = 30 -# Define the oauth2 scheme to get the token from the cookie -def oauth2_scheme_cookie( - access_token: Optional[str] = Cookie(None, alias="access_token"), - refresh_token: Optional[str] = Cookie(None, alias="refresh_token"), -) -> tuple[Optional[str], Optional[str]]: - return access_token, refresh_token +# --- Custom Exceptions --- + + +class AuthenticationError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"} + ) class PasswordValidationError(HTTPException): @@ -55,6 +59,25 @@ def __init__(self, field: str = "confirm_password"): ) +class InsufficientPermissionsError(HTTPException): + def __init__(self): + super().__init__( + status_code=403, + detail="You don't have permission to perform this action" + ) + + +# --- Helpers --- + + +# Define the oauth2 scheme to get the token from the cookie +def oauth2_scheme_cookie( + access_token: Optional[str] = Cookie(None, alias="access_token"), + refresh_token: Optional[str] = Cookie(None, alias="refresh_token"), +) -> tuple[Optional[str], Optional[str]]: + return access_token, refresh_token + + def create_password_validator(field_name: str = "password"): """ Factory function that creates a password validation decorator for Pydantic models. @@ -217,14 +240,6 @@ def get_user_from_tokens( return None, None, None -class AuthenticationError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_303_SEE_OTHER, - headers={"Location": "/login"} - ) - - def get_authenticated_user( tokens: tuple[Optional[str], Optional[str] ] = Depends(oauth2_scheme_cookie), diff --git a/utils/db.py b/utils/db.py index 6ad99e2..b65af2f 100644 --- a/utils/db.py +++ b/utils/db.py @@ -2,6 +2,7 @@ import logging from typing import Generator, Union, Sequence from dotenv import load_dotenv +from fastapi import HTTPException from sqlalchemy.engine import URL from sqlmodel import create_engine, Session, SQLModel, select from utils.models import Role, Permission, RolePermissionLink, default_roles, ValidPermissions @@ -57,7 +58,12 @@ def get_session() -> Generator[Session, None, None]: yield session -def assign_permissions_to_role(session: Session, role: Role, permissions: Union[list[Permission], Sequence[Permission]], check_first: bool = False) -> None: +def assign_permissions_to_role( + session: Session, + role: Role, + permissions: Union[list[Permission], Sequence[Permission]], + check_first: bool = False +) -> None: """ Assigns permissions to a role in the database. diff --git a/utils/models.py b/utils/models.py index 3f8ed1d..63d9ec2 100644 --- a/utils/models.py +++ b/utils/models.py @@ -2,7 +2,8 @@ from enum import Enum from uuid import uuid4 from datetime import datetime, UTC, timedelta -from typing import Optional, List +from typing import Optional, List, Union +from fastapi import HTTPException from sqlmodel import SQLModel, Field, Relationship from sqlalchemy import Column, Enum as SQLAlchemyEnum from sqlalchemy.orm import Mapped @@ -11,15 +12,35 @@ logger.setLevel(DEBUG) +# --- Helper functions --- + + def utc_time(): return datetime.now(UTC) +# --- Custom exceptions --- + + +class DataIntegrityError(HTTPException): + def __init__( + self, + resource: str = "Database resource" + ): + super().__init__( + status_code=500, + detail=( + f"{resource} is in a broken state; please contact a system administrator" + ) + ) + + +# --- Database models --- + + default_roles = ["Owner", "Administrator", "Member"] -# TODO: User with permission to create/edit roles can only assign permissions -# they themselves have. class ValidPermissions(Enum): DELETE_ORGANIZATION = "Delete Organization" EDIT_ORGANIZATION = "Edit Organization" @@ -190,11 +211,20 @@ def organizations(self) -> List[Organization]: organization_ids.add(role.organization_id) return organizations - def has_permission(self, permission: ValidPermissions, organization: Organization) -> bool: + def has_permission(self, permission: ValidPermissions, organization: Union[Organization, int]) -> bool: """ Check if the user has a specific permission for a given organization. """ + organization_id: Optional[int] = None + if isinstance(organization, Organization): + organization_id = organization.id + else: + organization_id = organization + + if not organization_id: + raise DataIntegrityError(resource="Organization ID") + for role in self.roles: - if role.organization_id == organization.id: + if role.organization_id == organization_id: return permission in [perm.name for perm in role.permissions] return False From 5ba8044413ecade85ca3210eacb14476a0ca493c Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 1 Dec 2024 22:02:00 +0000 Subject: [PATCH 48/73] Use POST for all routes (since HTML forms only support GET and POST) --- routers/role.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routers/role.py b/routers/role.py index 32bf9d7..1a89f2d 100644 --- a/routers/role.py +++ b/routers/role.py @@ -129,7 +129,7 @@ async def as_form( # -- Routes -- -@router.post("/", response_class=RedirectResponse) +@router.post("/create", response_class=RedirectResponse) def create_role( role: RoleCreate = Depends(RoleCreate.as_form), user: User = Depends(get_authenticated_user), @@ -168,7 +168,7 @@ def create_role( return RedirectResponse(url="/profile", status_code=303) -@router.put("/{role_id}", response_class=RedirectResponse) +@router.post("/update", response_class=RedirectResponse) def update_role( role: RoleUpdate = Depends(RoleUpdate.as_form), user: User = Depends(get_authenticated_user), @@ -227,7 +227,7 @@ def update_role( return RedirectResponse(url="/profile", status_code=303) -@router.delete("/{role_id}", response_class=RedirectResponse) +@router.post("/delete", response_class=RedirectResponse) def delete_role( role: RoleDelete = Depends(RoleDelete.as_form), user: User = Depends(get_authenticated_user), From 4224b223da5442df1bd1b94b95b8609493d4e437 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 1 Dec 2024 22:09:22 +0000 Subject: [PATCH 49/73] Re-render database model diagram and update LLMs.txt --- docs/static/llms.txt | 71 +++++++++++++++++++++++++++++++++++------ docs/static/schema.png | Bin 53704 -> 37008 bytes 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/docs/static/llms.txt b/docs/static/llms.txt index 8f5edce..0635d39 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -105,6 +105,8 @@ To use password recovery, register a [Resend](https://resend.com/) account, veri ### Start development database +To start the development database, run the following command in your terminal from the root directory: + ``` bash docker compose up -d ``` @@ -515,6 +517,8 @@ If you use VSCode with Docker to develop in a container, the following VSCode De Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. +*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* + ## Install development dependencies manually ### Python and Docker @@ -598,15 +602,32 @@ Set your desired database name, username, and password in the .env file. To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. +If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) + ## Start development database +To start the development database, run the following command in your terminal from the root directory: + ``` bash docker compose up -d ``` +If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*: + +``` bash +# Don't forget the -v flag to tear down the volume! +docker compose down -v +``` + +You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt: + +``` bash +docker compose up -d --force-recreate --build +``` + ## Run the development server -Make sure the development database is running and tables and default permissions/roles are created first. +Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory: ``` bash uvicorn main:app --host 0.0.0.0 --port 8000 --reload @@ -646,7 +667,8 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes - `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. - `session`: Provides a session for database operations in tests. - `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. -- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session. +- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture. +- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture. - `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. To run the tests, use these commands: @@ -661,10 +683,10 @@ To run the tests, use these commands: The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: ```bash -mypy +mypy . ``` -We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change! +We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change! ### Developing with LLMs @@ -705,7 +727,9 @@ We also create POST endpoints, which accept form submissions so the user can cre #### Routing patterns in this template -In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. +In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. + +We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: @@ -713,7 +737,9 @@ We divide our GET routes into authenticated and unauthenticated routes, using co # -- Authenticated Routes -- ``` -Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. +Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. + +Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. ### HTML templating with Jinja2 @@ -734,7 +760,7 @@ async def welcome(request: Request): ) ``` -In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. +In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. #### Form validation strategy @@ -890,9 +916,9 @@ graph.write_png('static/schema.png') ![Database Schema](static/schema.png) -#### Database operations +#### Database helpers -Database operations are handled by helper functions in `utils/db.py`. Key functions include: +Database operations are facilitated by helper functions in `utils/db.py`. Key functions include: - `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) - `get_connection_url()`: Creates a database connection URL from environment variables in `.env` @@ -909,6 +935,33 @@ async def get_users(session: Session = Depends(get_session)): The session automatically handles transaction management, ensuring that database operations are atomic and consistent. +#### Cascade deletes + +Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: + +```python +sa_relationship_kwargs={ + "cascade": "all, delete-orphan" +} +``` + +This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly. + +For example, + +```python +session.exec(delete(Role)) +``` + +will not trigger the cascade delete. Instead, we need to select the role objects and then delete them: + +```python +for role in session.exec(select(Role)).all(): + session.delete(role) +``` + +This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage. + # Deployment diff --git a/docs/static/schema.png b/docs/static/schema.png index 0656c093ae963491c3b20f5ae6013c1851ad38fd..ae8fbf981d4a80b1b907a3434cfc6320523dc2b9 100644 GIT binary patch literal 37008 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 TSG_&_n_{@lH`d22o!0z6nlPPZ literal 53704 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 From d03f1178a6c766f2a55cab902581a63437c5af81 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 1 Dec 2024 22:54:07 +0000 Subject: [PATCH 50/73] Added cursorrules as a static asset in addition to full docs --- docs/customization.qmd | 4 +- docs/static/documentation.txt | 1045 +++++++++++++++++++++++++++++++++ docs/static/llms.txt | 8 +- index.qmd | 4 +- 4 files changed, 1057 insertions(+), 4 deletions(-) create mode 100644 docs/static/documentation.txt diff --git a/docs/customization.qmd b/docs/customization.qmd index e43169e..62aa807 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -48,7 +48,9 @@ We find that mypy is an enormous time-saver, catching many errors early and grea ### Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. ## Project structure diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt new file mode 100644 index 0000000..0635d39 --- /dev/null +++ b/docs/static/documentation.txt @@ -0,0 +1,1045 @@ +# FastAPI, Jinja2, PostgreSQL Webapp Template + +![Screenshot of homepage](docs/static/Screenshot.png) + +## Quickstart + +This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html). + +## Features + +This template combines three of the most lightweight and performant open-source web development frameworks into a customizable webapp template with: + +- Pure Python backend +- Minimal-Javascript frontend +- Powerful, easy-to-manage database + +The template also includes full-featured secure auth with: + +- Token-based authentication +- Password recovery flow +- Role-based access control system + +## Design Philosophy + +The design philosophy of the template is to prefer low-level, best-in-class open-source frameworks that offer flexibility, scalability, and performance without vendor-lock-in. You'll find the template amazingly easy not only to understand and customize, but also to deploy to any major cloud hosting platform. + +## Tech Stack + +**Core frameworks:** + +- [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework +- [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine +- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine +- [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM + +**Additional technologies:** + +- [Poetry](https://python-poetry.org/): Python dependency manager +- [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework +- [Docker](https://www.docker.com/): development containerization +- [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline +- [Quarto](https://quarto.org/docs/): simple documentation website renderer +- [MyPy](https://mypy.readthedocs.io/en/stable/): static type checker for Python +- [Bootstrap](https://getbootstrap.com/): HTML/CSS styler +- [Resend](https://resend.com/): zero- or low-cost email service used for password recovery + +## Installation + +For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). + +### Python and Docker + +- [Python 3.12 or higher](https://www.python.org/downloads/) +- [Docker and Docker Compose](https://docs.docker.com/get-docker/) + +### PostgreSQL headers + +For Ubuntu/Debian: + +``` bash +sudo apt update && sudo apt install -y python3-dev libpq-dev +``` + +For macOS: + +``` bash +brew install postgresql +``` + +For Windows: + +- No installation required + +### Python dependencies + +1. Install Poetry + +``` bash +pipx install poetry +``` + +2. Install project dependencies + +``` bash +poetry install +``` + +3. Activate shell + +``` bash +poetry shell +``` + +(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) + +### Set environment variables + +Copy .env.example to .env with `cp .env.example .env`. + +Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. + +Set your desired database name, username, and password in the .env file. + +To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. + +### Start development database + +To start the development database, run the following command in your terminal from the root directory: + +``` bash +docker compose up -d +``` + +### Run the development server + +Make sure the development database is running and tables and default permissions/roles are created first. + +``` bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +Navigate to http://localhost:8000/ + +### Lint types with mypy + +``` bash +mypy . +``` + +## Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. + +``` {python} +#| echo: false +#| include: false +import re +from pathlib import Path + + +def extract_file_paths(quarto_yml_path): + """ + Extract href paths from _quarto.yml file. + Returns a list of .qmd file paths. + """ + with open(quarto_yml_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all href entries that point to .qmd files + pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$' + matches = re.findall(pattern, content, re.MULTILINE) + return matches + + +def process_qmd_content(file_path): + """ + Process a .qmd file by converting YAML frontmatter to markdown heading. + Returns the processed content as a string. + """ + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Replace YAML frontmatter with markdown heading + pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---' + processed_content = re.sub(pattern, r'# \1', content) + return processed_content + + +# Get the current working directory +base_dir = Path.cwd() +quarto_yml_path = base_dir / '_quarto.yml' + +# Extract file paths from _quarto.yml +qmd_files = extract_file_paths(quarto_yml_path) + +# Process each .qmd file and collect contents +processed_contents = [] +for qmd_file in qmd_files: + file_path = base_dir / qmd_file + if file_path.exists(): + processed_content = process_qmd_content(file_path) + processed_contents.append(processed_content) + +# Concatenate all contents with double newline separator +final_content = '\n\n'.join(processed_contents) + +# Ensure the output directory exists +output_dir = base_dir / 'docs' / 'static' +output_dir.mkdir(parents=True, exist_ok=True) + +# Write the concatenated content to the output file +output_path = output_dir / 'llms.txt' +with open(output_path, 'w', encoding='utf-8') as f: + f.write(final_content) +``` + +## Contributing + +Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. + +## License + +This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT License. See the LICENSE file for more details. + + +# Architecture + +## Data flow + +This application uses a Post-Redirect-Get (PRG) pattern. The user submits a form, which sends a POST request to a FastAPI endpoint on the server. The database is updated, and the user is redirected to a GET endpoint, which fetches the updated data and re-renders the Jinja2 page template with the new data. + +``` {python} +#| echo: false +#| include: false +from graphviz import Digraph + +dot = Digraph() +dot.attr(rankdir='TB') +dot.attr('node', shape='box', style='rounded') + +# Create client subgraph at top +with dot.subgraph(name='cluster_client') as client: + client.attr(label='Client') + client.attr(rank='topmost') + client.node('A', 'User submits form', fillcolor='lightblue', style='rounded,filled') + client.node('B', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled') + +# Create server subgraph below +with dot.subgraph(name='cluster_server') as server: + server.attr(label='Server') + server.node('C', 'Convert to Pydantic model', fillcolor='lightgreen', style='rounded,filled') + server.node('D', 'Optional custom validation', fillcolor='lightgreen', style='rounded,filled') + server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled') + server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled') + server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled') + server.node('H', 'Redirect to GET endpoint', fillcolor='lightgreen', style='rounded,filled') + server.node('I', 'Fetch updated data', fillcolor='lightgreen', style='rounded,filled') + server.node('K', 'Re-render Jinja2 page template', fillcolor='lightgreen', style='rounded,filled') + +with dot.subgraph(name='cluster_client_post') as client_post: + client_post.attr(label='Client') + client_post.attr(rank='bottommost') + client_post.node('J', 'Display rendered page', fillcolor='lightblue', style='rounded,filled') + +# Add visible edges +dot.edge('A', 'B') +dot.edge('B', 'A') +dot.edge('B', 'C', label='POST Request to FastAPI endpoint') +dot.edge('C', 'D') +dot.edge('C', 'F', label='RequestValidationError') +dot.edge('D', 'E', label='Valid data') +dot.edge('D', 'F', label='Custom Validation Error') +dot.edge('E', 'H', label='Data updated') +dot.edge('H', 'I') +dot.edge('I', 'K') +dot.edge('K', 'J', label='Return HTML') +dot.edge('F', 'G') +dot.edge('G', 'J', label='Return HTML') + +dot.render('static/data_flow', format='png', cleanup=True) +``` + +![Data flow diagram](static/data_flow.png) + +The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. + +## Form validation flow + +We've experimented with several approaches to validating form inputs in the FastAPI endpoints. + +### Objectives + +Ideally, on an invalid input, we would redirect the user back to the form, preserving their inputs and displaying an error message about which input was invalid. + +This would keep the error handling consistent with the PRG pattern described in the [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture) section of this documentation. + +To keep the code DRY, we'd also like to handle such validation with Pydantic dependencies, Python exceptions, and exception-handling middleware as much as possible. + +### Obstacles + +One challenge is that if we redirect back to the page with the form, the page is re-rendered with empty form fields. + +This can be overcome by passing the inputs from the request as context variables to the template. + +But that's a bit clunky, because then we have to support form-specific context variables in every form page and corresponding GET endpoint. + +Also, we have to: + +1. access the request object (which is not by default available to our middleware), and +2. extract the form inputs (at least one of which is invalid in this error case), and +3. pass the form inputs to the template (which is a bit challenging to do in a DRY way since there are different sets of form inputs for different forms). + +Solving these challenges is possible, but gets high-complexity pretty quickly. + +### Approaches + +The best solution, I think, is to use really robust client-side form validation to prevent invalid inputs from being sent to the server in the first place. That makes it less important what we do on the server side, although we still need to handle the server-side error case as a backup in the event that something slips past our validation on the client side. + +Here are some patterns we've considered for server-side error handling: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDApproachReturns to same pagePreserves form inputsFollows PRG patternComplexity
1Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" buttonNoYesYesLow
2Validate in FastAPI endpoint function body, redirect to origin page with error message query paramYesNoYesMedium
3Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
4Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
5Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level JavascriptYesYesNoLow
+ +Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. + +# Authentication + +## Security features + +This template implements a comprehensive authentication system with security best practices: + +1. **Token Security**: + - JWT-based with separate access/refresh tokens + - Strict expiry times (30 min access, 30 day refresh) + - Token type validation + - HTTP-only cookies + - Secure flag enabled + - SameSite=strict restriction + +2. **Password Security**: + - Strong password requirements enforced + - Bcrypt hashing with random salt + - Password reset tokens are single-use + - Reset tokens have expiration + +3. **Cookie Security**: + - HTTP-only prevents JavaScript access + - Secure flag ensures HTTPS only + - Strict SameSite prevents CSRF + +4. **Error Handling**: + - Validation errors properly handled + - Security-related errors don't leak information + - Comprehensive error logging + +The diagrams below show the main authentication flows. + +## Registration and login flow + +``` {python} +#| echo: false +#| include: false +from graphviz import Digraph + +# Create graph for registration/login +auth = Digraph(name='auth_flow') +auth.attr(rankdir='TB') +auth.attr('node', shape='box', style='rounded') + +# Client-side nodes +with auth.subgraph(name='cluster_client') as client: + client.attr(label='Client') + client.node('register_form', 'Submit registration', fillcolor='lightblue', style='rounded,filled') + client.node('login_form', 'Submit login', fillcolor='lightblue', style='rounded,filled') + client.node('store_cookies', 'Store secure cookies', fillcolor='lightblue', style='rounded,filled') + +# Server-side nodes +with auth.subgraph(name='cluster_server') as server: + server.attr(label='Server') + # Registration path + server.node('validate_register', 'Validate registration data', fillcolor='lightgreen', style='rounded,filled') + server.node('hash_new', 'Hash new password', fillcolor='lightgreen', style='rounded,filled') + server.node('store_user', 'Store user in database', fillcolor='lightgreen', style='rounded,filled') + + # Login path + server.node('validate_login', 'Validate login data', fillcolor='lightgreen', style='rounded,filled') + server.node('verify_password', 'Verify password hash', fillcolor='lightgreen', style='rounded,filled') + server.node('fetch_user', 'Fetch user from database', fillcolor='lightgreen', style='rounded,filled') + + # Common path + server.node('generate_tokens', 'Generate JWT tokens', fillcolor='lightgreen', style='rounded,filled') + +# Registration path +auth.edge('register_form', 'validate_register', 'POST /register') +auth.edge('validate_register', 'hash_new') +auth.edge('hash_new', 'store_user') +auth.edge('store_user', 'generate_tokens', 'Success') + +# Login path +auth.edge('login_form', 'validate_login', 'POST /login') +auth.edge('validate_login', 'fetch_user') +auth.edge('fetch_user', 'verify_password') +auth.edge('verify_password', 'generate_tokens', 'Success') + +# Common path +auth.edge('generate_tokens', 'store_cookies', 'Set-Cookie') + +auth.render('static/auth_flow', format='png', cleanup=True) +``` + +![Registration and login flow](static/auth_flow.png) + +## Password reset flow + +``` {python} +#| echo: false +#| include: false +from graphviz import Digraph + +# Create graph for password reset +reset = Digraph(name='reset_flow') +reset.attr(rankdir='TB') +reset.attr('node', shape='box', style='rounded') + +# Client-side nodes - using light blue fill +reset.node('forgot', 'User submits forgot password form', fillcolor='lightblue', style='rounded,filled') +reset.node('reset', 'User submits reset password form', fillcolor='lightblue', style='rounded,filled') +reset.node('email_client', 'User clicks reset link', fillcolor='lightblue', style='rounded,filled') + +# Server-side nodes - using light green fill +reset.node('validate', 'Validation', fillcolor='lightgreen', style='rounded,filled') +reset.node('token_gen', 'Generate reset token', fillcolor='lightgreen', style='rounded,filled') +reset.node('hash', 'Hash password', fillcolor='lightgreen', style='rounded,filled') +reset.node('email_server', 'Send email with Resend', fillcolor='lightgreen', style='rounded,filled') +reset.node('db', 'Database', shape='cylinder', fillcolor='lightgreen', style='filled') + +# Add edges with labels +reset.edge('forgot', 'token_gen', 'POST') +reset.edge('token_gen', 'db', 'Store') +reset.edge('token_gen', 'email_server', 'Add email/token as URL parameter') +reset.edge('email_server', 'email_client') +reset.edge('email_client', 'reset', 'Set email/token as form input') +reset.edge('reset', 'validate', 'POST') +reset.edge('validate', 'hash') +reset.edge('hash', 'db', 'Update') + +reset.render('static/reset_flow', format='png', cleanup=True) +``` + +![Password reset flow](static/reset_flow.png) + + +# Installation + +## Install all dependencies in a VSCode Dev Container + +If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all dependencies: + +``` json +{ + "name": "Python 3", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && pipx install poetry && poetry install && poetry shell", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {} + } +} +``` + +Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. + +*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* + +## Install development dependencies manually + +### Python and Docker + +- [Python 3.12 or higher](https://www.python.org/downloads/) +- [Docker and Docker Compose](https://docs.docker.com/get-docker/) + +### PostgreSQL headers + +For Ubuntu/Debian: + +``` bash +sudo apt update && sudo apt install -y python3-dev libpq-dev +``` + +For macOS: + +``` bash +brew install postgresql +``` + +For Windows: + +- No installation required + +### Python dependencies + +1. Install Poetry + +``` bash +pipx install poetry +``` + +2. Install project dependencies + +``` bash +poetry install +``` + +3. Activate shell + +``` bash +poetry shell +``` + +(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) + +## Install documentation dependencies manually + +### Quarto CLI + +To render the project documentation, you will need to download and install the [Quarto CLI](https://quarto.org/docs/get-started/) for your operating system. + +### Graphviz + +Architecture diagrams in the documentation are rendered with [Graphviz](https://graphviz.org/). + +For macOS: + +``` bash +brew install graphviz +``` + +For Ubuntu/Debian: + +``` bash +sudo apt update && sudo apt install -y graphviz +``` + +For Windows: + +- Download and install from [Graphviz.org](https://graphviz.org/download/#windows) + +## Set environment variables + +Copy .env.example to .env with `cp .env.example .env`. + +Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. + +Set your desired database name, username, and password in the .env file. + +To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. + +If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) + +## Start development database + +To start the development database, run the following command in your terminal from the root directory: + +``` bash +docker compose up -d +``` + +If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*: + +``` bash +# Don't forget the -v flag to tear down the volume! +docker compose down -v +``` + +You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt: + +``` bash +docker compose up -d --force-recreate --build +``` + +## Run the development server + +Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory: + +``` bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +Navigate to http://localhost:8000/ + +## Lint types with mypy + +``` bash +mypy . +``` + + +# Customization + +## Development workflow + +### Dependency management with Poetry + +The project uses Poetry to manage dependencies: + +- Add new dependency: `poetry add ` +- Add development dependency: `poetry add --dev ` +- Remove dependency: `poetry remove ` +- Update lock file: `poetry lock` +- Install dependencies: `poetry install` +- Update all dependencies: `poetry update` + +### Testing + +The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! + +The following fixtures, defined in `tests/conftest.py`, are available in the test suite: + +- `engine`: Creates a new SQLModel engine for the test database. +- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. +- `session`: Provides a session for database operations in tests. +- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. +- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture. +- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture. +- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. + +To run the tests, use these commands: + +- Run all tests: `pytest` +- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` +- Run particular test files by name: `pytest ` +- Run particular tests by name: `pytest -k ` + +### Type checking with mypy + +The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: + +```bash +mypy . +``` + +We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change! + +### Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. + +## Project structure + +### Customizable folders and files + +- FastAPI application entry point and GET routes: `main.py` +- FastAPI POST routes: `routers/` + - User authentication endpoints: `auth.py` + - User profile management endpoints: `user.py` + - Organization management endpoints: `organization.py` + - Role management endpoints: `role.py` +- Jinja2 templates: `templates/` +- Static assets: `static/` +- Unit tests: `tests/` +- Test database configuration: `docker-compose.yml` +- Helper functions: `utils/` + - Auth helpers: `auth.py` + - Database helpers: `db.py` + - Database models: `models.py` +- Environment variables: `.env` +- CI/CD configuration: `.github/` +- Project configuration: `pyproject.toml` +- Quarto documentation: + - Source: `index.qmd` + `docs/` + - Configuration: `_quarto.yml` + +Most everything else is auto-generated and should not be manually modified. + +### Defining a web backend with FastAPI + +We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. + +We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) + +#### Routing patterns in this template + +In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. + +We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. + +We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: + +```python +# -- Authenticated Routes -- +``` + +Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. + +Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. + +### HTML templating with Jinja2 + +To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. + +With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. + +#### Context variables + +Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: + +```python +@app.get("/welcome") +async def welcome(request: Request): + return templates.TemplateResponse( + "welcome.html", + {"username": "Alice"} + ) +``` + +In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. + +#### Form validation strategy + +While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. + +Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: +- Provide immediate feedback to users +- Reduce server load +- Improve user experience by avoiding round-trips to the server +- Prevent malformed data from ever reaching the backend + +Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. + +### Writing type annotated code + +Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. + +If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. + +For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. + +#### Defining request models and custom validators + +For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: + +```python +class PasswordMismatchError(HTTPException): + def __init__(self, field: str = "confirm_password"): + super().__init__( + status_code=422, + detail={ + "field": field, + "message": "The passwords you entered do not match" + } + ) + +class UserRegister(BaseModel): + name: str + email: EmailStr + password: str + confirm_password: str + + # Custom validators are added as class attributes + @field_validator("confirm_password", check_fields=False) + def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: + if v != values["password"]: + raise PasswordMismatchError() + return v + # ... +``` + +We then add this request model as a dependency in the signature of our POST route: + +```python +@app.post("/register") +async def register(request: UserRegister = Depends()): + # ... +``` + +When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. + +(Note that these examples are simplified versions of the actual code.) + +#### Converting form data to request models + +In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: + +```python +class UserRegister(BaseModel): + # ... + + @classmethod + async def as_form( + cls, + name: str = Form(...), + email: EmailStr = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) + ): + return cls( + name=name, + email=email, + password=password, + confirm_password=confirm_password + ) +``` + +#### Middleware exception handling + +Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. + +This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses. + +Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific. + +Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: + +```python +@app.exception_handler(PasswordMismatchError) +async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): + return templates.TemplateResponse( + request, + "errors/validation_error.html", + { + "status_code": 422, + "errors": {"error": exc.detail} + }, + status_code=422, + ) +``` + +### Database configuration and access with SQLModel + +SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. + +#### Models and relationships + +Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: + +- `Organization`: Represents a company or team +- `User`: Represents a user account +- `Role`: Represents a discrete set of user permissions within an organization +- `Permission`: Represents specific actions a user can perform +- `RolePermissionLink`: Maps roles to their allowed permissions +- `PasswordResetToken`: Manages password reset functionality + +Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: + +```{python} +#| echo: false +#| warning: false +import sys +sys.path.append("..") +from utils.models import * +from utils.db import engine +from sqlalchemy import MetaData +from sqlalchemy_schemadisplay import create_schema_graph + +# Create the directed graph +graph = create_schema_graph( + engine=engine, + metadata=SQLModel.metadata, + show_datatypes=True, + show_indexes=True, + rankdir='TB', + concentrate=False +) + +# Save the graph +graph.write_png('static/schema.png') +``` + +![Database Schema](static/schema.png) + + +#### Database helpers + +Database operations are facilitated by helper functions in `utils/db.py`. Key functions include: + +- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) +- `get_connection_url()`: Creates a database connection URL from environment variables in `.env` +- `get_session()`: Provides a database session for performing operations + +To perform database operations in route handlers, inject the database session as a dependency: + +```python +@app.get("/users") +async def get_users(session: Session = Depends(get_session)): + users = session.exec(select(User)).all() + return users +``` + +The session automatically handles transaction management, ensuring that database operations are atomic and consistent. + +#### Cascade deletes + +Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: + +```python +sa_relationship_kwargs={ + "cascade": "all, delete-orphan" +} +``` + +This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly. + +For example, + +```python +session.exec(delete(Role)) +``` + +will not trigger the cascade delete. Instead, we need to select the role objects and then delete them: + +```python +for role in session.exec(select(Role)).all(): + session.delete(role) +``` + +This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage. + + +# Deployment + +## Under construction + +# Contributing + +## Contributors + +### Opening issues and bug reports + +When opening a new issue or submitting a bug report, please include: + +1. A clear, descriptive title +2. For bug reports: + - Description of the expected behavior + - Description of the actual behavior + - Steps to reproduce the issue + - Version information (OS, Python version, package version) + - Any relevant error messages or screenshots +3. For feature requests: + - Description of the proposed feature + - Use case or motivation for the feature + - Any implementation suggestions (optional) + +Labels help categorize issues: +- Use `bug` for reporting problems +- Use `enhancement` for feature requests +- Use `documentation` for documentation improvements +- Use `question` for general queries + +### Contributing code + +To contribute code to the project: + +1. Fork the repository and clone your fork locally +2. Create a new branch from `main` with a descriptive name +3. Review the [customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/customization.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/architecture.html), and [authentication](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/authentication.html) pages for guidance on design patterns and code structure and style +4. Ensure all tests pass, including `mypy` type checking +5. Stage, commit, and push your changes to the branch: + - Use clear, descriptive commit messages + - Keep commits focused and atomic +6. Submit your pull request: + - Provide a clear description of the changes + - Link to any related issues + +### Rendering the documentation + +The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you ,make changes to the `.qmd` files in the root folder and the `docs` folder, run the following commands to re-render the docs: + +``` bash +# To render the documentation website +quarto render +# To render the README +quarto render index.qmd --output-dir . --output README.md --to gfm +``` + +Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. + +Note that even if your pull request is merged, your changes will not be reflected on the live website until a maintainer republishes the docs. + +## Maintainers + +### Git flow + +When creating new features, + +1. Open a Github issue with the label `feature` and assign it to yourself. +2. Create a new branch from the issue sidebar. +3. Follow the instructions in the popup to check out the branch locally and make your changes on the branch. +4. Commit your changes and push to the branch. +5. When you are ready to merge, open a pull request from the branch to main. +6. Assign someone else for code review. + +### Publishing the documentation + +To publish the documentation to GitHub Pages, run the following command: + +``` bash +quarto publish gh-pages +``` diff --git a/docs/static/llms.txt b/docs/static/llms.txt index 0635d39..4d8f51c 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -129,7 +129,9 @@ mypy . ## Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. ``` {python} #| echo: false @@ -690,7 +692,9 @@ We find that mypy is an enormous time-saver, catching many errors early and grea ### Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. ## Project structure diff --git a/index.qmd b/index.qmd index 92279bb..2fb29fe 100644 --- a/index.qmd +++ b/index.qmd @@ -131,7 +131,9 @@ mypy . ## Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. ``` {python} #| echo: false From 722240be0d4552081e7366599fac2192c3889982 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Sun, 1 Dec 2024 23:07:16 +0000 Subject: [PATCH 51/73] copy LLMs.txt and documentation.txt into website output dir --- _quarto.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/_quarto.yml b/_quarto.yml index b0f3023..c28f729 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -1,6 +1,9 @@ project: type: website output-dir: _docs + post-render: + - "cp docs/static/llms.txt _docs/docs/static/llms.txt" + - "cp docs/static/documentation.txt _docs/docs/static/documentation.txt" website: title: "FastAPI Webapp Template" From 47bcdd17b553ec5156b9d02320e4f5b574f1376e Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 01:13:37 +0000 Subject: [PATCH 52/73] Correct file paths for serving and rendering static text files --- _quarto.yml | 3 - docs/customization.qmd | 4 +- docs/static/llms.txt | 1049 ---------------------------------------- index.qmd | 10 +- 4 files changed, 7 insertions(+), 1059 deletions(-) diff --git a/_quarto.yml b/_quarto.yml index c28f729..b0f3023 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -1,9 +1,6 @@ project: type: website output-dir: _docs - post-render: - - "cp docs/static/llms.txt _docs/docs/static/llms.txt" - - "cp docs/static/documentation.txt _docs/docs/static/documentation.txt" website: title: "FastAPI Webapp Template" diff --git a/docs/customization.qmd b/docs/customization.qmd index 62aa807..463b5c2 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -48,9 +48,9 @@ We find that mypy is an enormous time-saver, catching many errors early and grea ### Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). -We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. +We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding. ## Project structure diff --git a/docs/static/llms.txt b/docs/static/llms.txt index 4d8f51c..e69de29 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -1,1049 +0,0 @@ -# FastAPI, Jinja2, PostgreSQL Webapp Template - -![Screenshot of homepage](docs/static/Screenshot.png) - -## Quickstart - -This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html). - -## Features - -This template combines three of the most lightweight and performant open-source web development frameworks into a customizable webapp template with: - -- Pure Python backend -- Minimal-Javascript frontend -- Powerful, easy-to-manage database - -The template also includes full-featured secure auth with: - -- Token-based authentication -- Password recovery flow -- Role-based access control system - -## Design Philosophy - -The design philosophy of the template is to prefer low-level, best-in-class open-source frameworks that offer flexibility, scalability, and performance without vendor-lock-in. You'll find the template amazingly easy not only to understand and customize, but also to deploy to any major cloud hosting platform. - -## Tech Stack - -**Core frameworks:** - -- [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework -- [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine -- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine -- [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM - -**Additional technologies:** - -- [Poetry](https://python-poetry.org/): Python dependency manager -- [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework -- [Docker](https://www.docker.com/): development containerization -- [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline -- [Quarto](https://quarto.org/docs/): simple documentation website renderer -- [MyPy](https://mypy.readthedocs.io/en/stable/): static type checker for Python -- [Bootstrap](https://getbootstrap.com/): HTML/CSS styler -- [Resend](https://resend.com/): zero- or low-cost email service used for password recovery - -## Installation - -For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). - -### Python and Docker - -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) - -### PostgreSQL headers - -For Ubuntu/Debian: - -``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev -``` - -For macOS: - -``` bash -brew install postgresql -``` - -For Windows: - -- No installation required - -### Python dependencies - -1. Install Poetry - -``` bash -pipx install poetry -``` - -2. Install project dependencies - -``` bash -poetry install -``` - -3. Activate shell - -``` bash -poetry shell -``` - -(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) - -### Set environment variables - -Copy .env.example to .env with `cp .env.example .env`. - -Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. - -Set your desired database name, username, and password in the .env file. - -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. - -### Start development database - -To start the development database, run the following command in your terminal from the root directory: - -``` bash -docker compose up -d -``` - -### Run the development server - -Make sure the development database is running and tables and default permissions/roles are created first. - -``` bash -uvicorn main:app --host 0.0.0.0 --port 8000 --reload -``` - -Navigate to http://localhost:8000/ - -### Lint types with mypy - -``` bash -mypy . -``` - -## Developing with LLMs - -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). - -We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. - -``` {python} -#| echo: false -#| include: false -import re -from pathlib import Path - - -def extract_file_paths(quarto_yml_path): - """ - Extract href paths from _quarto.yml file. - Returns a list of .qmd file paths. - """ - with open(quarto_yml_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Find all href entries that point to .qmd files - pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$' - matches = re.findall(pattern, content, re.MULTILINE) - return matches - - -def process_qmd_content(file_path): - """ - Process a .qmd file by converting YAML frontmatter to markdown heading. - Returns the processed content as a string. - """ - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Replace YAML frontmatter with markdown heading - pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---' - processed_content = re.sub(pattern, r'# \1', content) - return processed_content - - -# Get the current working directory -base_dir = Path.cwd() -quarto_yml_path = base_dir / '_quarto.yml' - -# Extract file paths from _quarto.yml -qmd_files = extract_file_paths(quarto_yml_path) - -# Process each .qmd file and collect contents -processed_contents = [] -for qmd_file in qmd_files: - file_path = base_dir / qmd_file - if file_path.exists(): - processed_content = process_qmd_content(file_path) - processed_contents.append(processed_content) - -# Concatenate all contents with double newline separator -final_content = '\n\n'.join(processed_contents) - -# Ensure the output directory exists -output_dir = base_dir / 'docs' / 'static' -output_dir.mkdir(parents=True, exist_ok=True) - -# Write the concatenated content to the output file -output_path = output_dir / 'llms.txt' -with open(output_path, 'w', encoding='utf-8') as f: - f.write(final_content) -``` - -## Contributing - -Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. - -## License - -This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT License. See the LICENSE file for more details. - - -# Architecture - -## Data flow - -This application uses a Post-Redirect-Get (PRG) pattern. The user submits a form, which sends a POST request to a FastAPI endpoint on the server. The database is updated, and the user is redirected to a GET endpoint, which fetches the updated data and re-renders the Jinja2 page template with the new data. - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -dot = Digraph() -dot.attr(rankdir='TB') -dot.attr('node', shape='box', style='rounded') - -# Create client subgraph at top -with dot.subgraph(name='cluster_client') as client: - client.attr(label='Client') - client.attr(rank='topmost') - client.node('A', 'User submits form', fillcolor='lightblue', style='rounded,filled') - client.node('B', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled') - -# Create server subgraph below -with dot.subgraph(name='cluster_server') as server: - server.attr(label='Server') - server.node('C', 'Convert to Pydantic model', fillcolor='lightgreen', style='rounded,filled') - server.node('D', 'Optional custom validation', fillcolor='lightgreen', style='rounded,filled') - server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled') - server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled') - server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled') - server.node('H', 'Redirect to GET endpoint', fillcolor='lightgreen', style='rounded,filled') - server.node('I', 'Fetch updated data', fillcolor='lightgreen', style='rounded,filled') - server.node('K', 'Re-render Jinja2 page template', fillcolor='lightgreen', style='rounded,filled') - -with dot.subgraph(name='cluster_client_post') as client_post: - client_post.attr(label='Client') - client_post.attr(rank='bottommost') - client_post.node('J', 'Display rendered page', fillcolor='lightblue', style='rounded,filled') - -# Add visible edges -dot.edge('A', 'B') -dot.edge('B', 'A') -dot.edge('B', 'C', label='POST Request to FastAPI endpoint') -dot.edge('C', 'D') -dot.edge('C', 'F', label='RequestValidationError') -dot.edge('D', 'E', label='Valid data') -dot.edge('D', 'F', label='Custom Validation Error') -dot.edge('E', 'H', label='Data updated') -dot.edge('H', 'I') -dot.edge('I', 'K') -dot.edge('K', 'J', label='Return HTML') -dot.edge('F', 'G') -dot.edge('G', 'J', label='Return HTML') - -dot.render('static/data_flow', format='png', cleanup=True) -``` - -![Data flow diagram](static/data_flow.png) - -The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. - -## Form validation flow - -We've experimented with several approaches to validating form inputs in the FastAPI endpoints. - -### Objectives - -Ideally, on an invalid input, we would redirect the user back to the form, preserving their inputs and displaying an error message about which input was invalid. - -This would keep the error handling consistent with the PRG pattern described in the [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture) section of this documentation. - -To keep the code DRY, we'd also like to handle such validation with Pydantic dependencies, Python exceptions, and exception-handling middleware as much as possible. - -### Obstacles - -One challenge is that if we redirect back to the page with the form, the page is re-rendered with empty form fields. - -This can be overcome by passing the inputs from the request as context variables to the template. - -But that's a bit clunky, because then we have to support form-specific context variables in every form page and corresponding GET endpoint. - -Also, we have to: - -1. access the request object (which is not by default available to our middleware), and -2. extract the form inputs (at least one of which is invalid in this error case), and -3. pass the form inputs to the template (which is a bit challenging to do in a DRY way since there are different sets of form inputs for different forms). - -Solving these challenges is possible, but gets high-complexity pretty quickly. - -### Approaches - -The best solution, I think, is to use really robust client-side form validation to prevent invalid inputs from being sent to the server in the first place. That makes it less important what we do on the server side, although we still need to handle the server-side error case as a backup in the event that something slips past our validation on the client side. - -Here are some patterns we've considered for server-side error handling: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDApproachReturns to same pagePreserves form inputsFollows PRG patternComplexity
1Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" buttonNoYesYesLow
2Validate in FastAPI endpoint function body, redirect to origin page with error message query paramYesNoYesMedium
3Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
4Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
5Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level JavascriptYesYesNoLow
- -Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. - -# Authentication - -## Security features - -This template implements a comprehensive authentication system with security best practices: - -1. **Token Security**: - - JWT-based with separate access/refresh tokens - - Strict expiry times (30 min access, 30 day refresh) - - Token type validation - - HTTP-only cookies - - Secure flag enabled - - SameSite=strict restriction - -2. **Password Security**: - - Strong password requirements enforced - - Bcrypt hashing with random salt - - Password reset tokens are single-use - - Reset tokens have expiration - -3. **Cookie Security**: - - HTTP-only prevents JavaScript access - - Secure flag ensures HTTPS only - - Strict SameSite prevents CSRF - -4. **Error Handling**: - - Validation errors properly handled - - Security-related errors don't leak information - - Comprehensive error logging - -The diagrams below show the main authentication flows. - -## Registration and login flow - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -# Create graph for registration/login -auth = Digraph(name='auth_flow') -auth.attr(rankdir='TB') -auth.attr('node', shape='box', style='rounded') - -# Client-side nodes -with auth.subgraph(name='cluster_client') as client: - client.attr(label='Client') - client.node('register_form', 'Submit registration', fillcolor='lightblue', style='rounded,filled') - client.node('login_form', 'Submit login', fillcolor='lightblue', style='rounded,filled') - client.node('store_cookies', 'Store secure cookies', fillcolor='lightblue', style='rounded,filled') - -# Server-side nodes -with auth.subgraph(name='cluster_server') as server: - server.attr(label='Server') - # Registration path - server.node('validate_register', 'Validate registration data', fillcolor='lightgreen', style='rounded,filled') - server.node('hash_new', 'Hash new password', fillcolor='lightgreen', style='rounded,filled') - server.node('store_user', 'Store user in database', fillcolor='lightgreen', style='rounded,filled') - - # Login path - server.node('validate_login', 'Validate login data', fillcolor='lightgreen', style='rounded,filled') - server.node('verify_password', 'Verify password hash', fillcolor='lightgreen', style='rounded,filled') - server.node('fetch_user', 'Fetch user from database', fillcolor='lightgreen', style='rounded,filled') - - # Common path - server.node('generate_tokens', 'Generate JWT tokens', fillcolor='lightgreen', style='rounded,filled') - -# Registration path -auth.edge('register_form', 'validate_register', 'POST /register') -auth.edge('validate_register', 'hash_new') -auth.edge('hash_new', 'store_user') -auth.edge('store_user', 'generate_tokens', 'Success') - -# Login path -auth.edge('login_form', 'validate_login', 'POST /login') -auth.edge('validate_login', 'fetch_user') -auth.edge('fetch_user', 'verify_password') -auth.edge('verify_password', 'generate_tokens', 'Success') - -# Common path -auth.edge('generate_tokens', 'store_cookies', 'Set-Cookie') - -auth.render('static/auth_flow', format='png', cleanup=True) -``` - -![Registration and login flow](static/auth_flow.png) - -## Password reset flow - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -# Create graph for password reset -reset = Digraph(name='reset_flow') -reset.attr(rankdir='TB') -reset.attr('node', shape='box', style='rounded') - -# Client-side nodes - using light blue fill -reset.node('forgot', 'User submits forgot password form', fillcolor='lightblue', style='rounded,filled') -reset.node('reset', 'User submits reset password form', fillcolor='lightblue', style='rounded,filled') -reset.node('email_client', 'User clicks reset link', fillcolor='lightblue', style='rounded,filled') - -# Server-side nodes - using light green fill -reset.node('validate', 'Validation', fillcolor='lightgreen', style='rounded,filled') -reset.node('token_gen', 'Generate reset token', fillcolor='lightgreen', style='rounded,filled') -reset.node('hash', 'Hash password', fillcolor='lightgreen', style='rounded,filled') -reset.node('email_server', 'Send email with Resend', fillcolor='lightgreen', style='rounded,filled') -reset.node('db', 'Database', shape='cylinder', fillcolor='lightgreen', style='filled') - -# Add edges with labels -reset.edge('forgot', 'token_gen', 'POST') -reset.edge('token_gen', 'db', 'Store') -reset.edge('token_gen', 'email_server', 'Add email/token as URL parameter') -reset.edge('email_server', 'email_client') -reset.edge('email_client', 'reset', 'Set email/token as form input') -reset.edge('reset', 'validate', 'POST') -reset.edge('validate', 'hash') -reset.edge('hash', 'db', 'Update') - -reset.render('static/reset_flow', format='png', cleanup=True) -``` - -![Password reset flow](static/reset_flow.png) - - -# Installation - -## Install all dependencies in a VSCode Dev Container - -If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all dependencies: - -``` json -{ - "name": "Python 3", - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && pipx install poetry && poetry install && poetry shell", - "features": { - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, - "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {} - } -} -``` - -Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. - -*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* - -## Install development dependencies manually - -### Python and Docker - -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) - -### PostgreSQL headers - -For Ubuntu/Debian: - -``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev -``` - -For macOS: - -``` bash -brew install postgresql -``` - -For Windows: - -- No installation required - -### Python dependencies - -1. Install Poetry - -``` bash -pipx install poetry -``` - -2. Install project dependencies - -``` bash -poetry install -``` - -3. Activate shell - -``` bash -poetry shell -``` - -(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) - -## Install documentation dependencies manually - -### Quarto CLI - -To render the project documentation, you will need to download and install the [Quarto CLI](https://quarto.org/docs/get-started/) for your operating system. - -### Graphviz - -Architecture diagrams in the documentation are rendered with [Graphviz](https://graphviz.org/). - -For macOS: - -``` bash -brew install graphviz -``` - -For Ubuntu/Debian: - -``` bash -sudo apt update && sudo apt install -y graphviz -``` - -For Windows: - -- Download and install from [Graphviz.org](https://graphviz.org/download/#windows) - -## Set environment variables - -Copy .env.example to .env with `cp .env.example .env`. - -Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. - -Set your desired database name, username, and password in the .env file. - -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. - -If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) - -## Start development database - -To start the development database, run the following command in your terminal from the root directory: - -``` bash -docker compose up -d -``` - -If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*: - -``` bash -# Don't forget the -v flag to tear down the volume! -docker compose down -v -``` - -You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt: - -``` bash -docker compose up -d --force-recreate --build -``` - -## Run the development server - -Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory: - -``` bash -uvicorn main:app --host 0.0.0.0 --port 8000 --reload -``` - -Navigate to http://localhost:8000/ - -## Lint types with mypy - -``` bash -mypy . -``` - - -# Customization - -## Development workflow - -### Dependency management with Poetry - -The project uses Poetry to manage dependencies: - -- Add new dependency: `poetry add ` -- Add development dependency: `poetry add --dev ` -- Remove dependency: `poetry remove ` -- Update lock file: `poetry lock` -- Install dependencies: `poetry install` -- Update all dependencies: `poetry update` - -### Testing - -The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! - -The following fixtures, defined in `tests/conftest.py`, are available in the test suite: - -- `engine`: Creates a new SQLModel engine for the test database. -- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. -- `session`: Provides a session for database operations in tests. -- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables. -- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture. -- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture. -- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password. - -To run the tests, use these commands: - -- Run all tests: `pytest` -- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` -- Run particular test files by name: `pytest ` -- Run particular tests by name: `pytest -k ` - -### Type checking with mypy - -The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory: - -```bash -mypy . -``` - -We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change! - -### Developing with LLMs - -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). - -We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. - -## Project structure - -### Customizable folders and files - -- FastAPI application entry point and GET routes: `main.py` -- FastAPI POST routes: `routers/` - - User authentication endpoints: `auth.py` - - User profile management endpoints: `user.py` - - Organization management endpoints: `organization.py` - - Role management endpoints: `role.py` -- Jinja2 templates: `templates/` -- Static assets: `static/` -- Unit tests: `tests/` -- Test database configuration: `docker-compose.yml` -- Helper functions: `utils/` - - Auth helpers: `auth.py` - - Database helpers: `db.py` - - Database models: `models.py` -- Environment variables: `.env` -- CI/CD configuration: `.github/` -- Project configuration: `pyproject.toml` -- Quarto documentation: - - Source: `index.qmd` + `docs/` - - Configuration: `_quarto.yml` - -Most everything else is auto-generated and should not be manually modified. - -### Defining a web backend with FastAPI - -We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. - -We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) - -#### Routing patterns in this template - -In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. - -We name our GET routes using the convention `read_`, where `` is the name of the page, to indicate that they are read-only endpoints that do not modify the database. - -We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: - -```python -# -- Authenticated Routes -- -``` - -Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. - -Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`. - -### HTML templating with Jinja2 - -To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. - -With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. - -#### Context variables - -Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: - -```python -@app.get("/welcome") -async def welcome(request: Request): - return templates.TemplateResponse( - "welcome.html", - {"username": "Alice"} - ) -``` - -In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. - -#### Form validation strategy - -While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. - -Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: -- Provide immediate feedback to users -- Reduce server load -- Improve user experience by avoiding round-trips to the server -- Prevent malformed data from ever reaching the backend - -Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. - -### Writing type annotated code - -Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. - -If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. - -For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. - -#### Defining request models and custom validators - -For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: - -```python -class PasswordMismatchError(HTTPException): - def __init__(self, field: str = "confirm_password"): - super().__init__( - status_code=422, - detail={ - "field": field, - "message": "The passwords you entered do not match" - } - ) - -class UserRegister(BaseModel): - name: str - email: EmailStr - password: str - confirm_password: str - - # Custom validators are added as class attributes - @field_validator("confirm_password", check_fields=False) - def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: - if v != values["password"]: - raise PasswordMismatchError() - return v - # ... -``` - -We then add this request model as a dependency in the signature of our POST route: - -```python -@app.post("/register") -async def register(request: UserRegister = Depends()): - # ... -``` - -When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. - -(Note that these examples are simplified versions of the actual code.) - -#### Converting form data to request models - -In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: - -```python -class UserRegister(BaseModel): - # ... - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - password: str = Form(...), - confirm_password: str = Form(...) - ): - return cls( - name=name, - email=email, - password=password, - confirm_password=confirm_password - ) -``` - -#### Middleware exception handling - -Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. - -This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses. - -Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific. - -Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: - -```python -@app.exception_handler(PasswordMismatchError) -async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): - return templates.TemplateResponse( - request, - "errors/validation_error.html", - { - "status_code": 422, - "errors": {"error": exc.detail} - }, - status_code=422, - ) -``` - -### Database configuration and access with SQLModel - -SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. - -#### Models and relationships - -Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: - -- `Organization`: Represents a company or team -- `User`: Represents a user account -- `Role`: Represents a discrete set of user permissions within an organization -- `Permission`: Represents specific actions a user can perform -- `RolePermissionLink`: Maps roles to their allowed permissions -- `PasswordResetToken`: Manages password reset functionality - -Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: - -```{python} -#| echo: false -#| warning: false -import sys -sys.path.append("..") -from utils.models import * -from utils.db import engine -from sqlalchemy import MetaData -from sqlalchemy_schemadisplay import create_schema_graph - -# Create the directed graph -graph = create_schema_graph( - engine=engine, - metadata=SQLModel.metadata, - show_datatypes=True, - show_indexes=True, - rankdir='TB', - concentrate=False -) - -# Save the graph -graph.write_png('static/schema.png') -``` - -![Database Schema](static/schema.png) - - -#### Database helpers - -Database operations are facilitated by helper functions in `utils/db.py`. Key functions include: - -- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) -- `get_connection_url()`: Creates a database connection URL from environment variables in `.env` -- `get_session()`: Provides a database session for performing operations - -To perform database operations in route handlers, inject the database session as a dependency: - -```python -@app.get("/users") -async def get_users(session: Session = Depends(get_session)): - users = session.exec(select(User)).all() - return users -``` - -The session automatically handles transaction management, ensuring that database operations are atomic and consistent. - -#### Cascade deletes - -Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: - -```python -sa_relationship_kwargs={ - "cascade": "all, delete-orphan" -} -``` - -This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly. - -For example, - -```python -session.exec(delete(Role)) -``` - -will not trigger the cascade delete. Instead, we need to select the role objects and then delete them: - -```python -for role in session.exec(select(Role)).all(): - session.delete(role) -``` - -This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage. - - -# Deployment - -## Under construction - -# Contributing - -## Contributors - -### Opening issues and bug reports - -When opening a new issue or submitting a bug report, please include: - -1. A clear, descriptive title -2. For bug reports: - - Description of the expected behavior - - Description of the actual behavior - - Steps to reproduce the issue - - Version information (OS, Python version, package version) - - Any relevant error messages or screenshots -3. For feature requests: - - Description of the proposed feature - - Use case or motivation for the feature - - Any implementation suggestions (optional) - -Labels help categorize issues: -- Use `bug` for reporting problems -- Use `enhancement` for feature requests -- Use `documentation` for documentation improvements -- Use `question` for general queries - -### Contributing code - -To contribute code to the project: - -1. Fork the repository and clone your fork locally -2. Create a new branch from `main` with a descriptive name -3. Review the [customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/customization.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/architecture.html), and [authentication](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/authentication.html) pages for guidance on design patterns and code structure and style -4. Ensure all tests pass, including `mypy` type checking -5. Stage, commit, and push your changes to the branch: - - Use clear, descriptive commit messages - - Keep commits focused and atomic -6. Submit your pull request: - - Provide a clear description of the changes - - Link to any related issues - -### Rendering the documentation - -The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you ,make changes to the `.qmd` files in the root folder and the `docs` folder, run the following commands to re-render the docs: - -``` bash -# To render the documentation website -quarto render -# To render the README -quarto render index.qmd --output-dir . --output README.md --to gfm -``` - -Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. - -Note that even if your pull request is merged, your changes will not be reflected on the live website until a maintainer republishes the docs. - -## Maintainers - -### Git flow - -When creating new features, - -1. Open a Github issue with the label `feature` and assign it to yourself. -2. Create a new branch from the issue sidebar. -3. Follow the instructions in the popup to check out the branch locally and make your changes on the branch. -4. Commit your changes and push to the branch. -5. When you are ready to merge, open a pull request from the branch to main. -6. Assign someone else for code review. - -### Publishing the documentation - -To publish the documentation to GitHub Pages, run the following command: - -``` bash -quarto publish gh-pages -``` diff --git a/index.qmd b/index.qmd index 2fb29fe..93ebeef 100644 --- a/index.qmd +++ b/index.qmd @@ -131,10 +131,6 @@ mypy . ## Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](https://promptlytechnologies.com/docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). - -We have also exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/documentation.txt) for easy downloading and embedding. - ``` {python} #| echo: false #| include: false @@ -193,11 +189,15 @@ output_dir = base_dir / 'docs' / 'static' output_dir.mkdir(parents=True, exist_ok=True) # Write the concatenated content to the output file -output_path = output_dir / 'llms.txt' +output_path = output_dir / 'documentation.txt' with open(output_path, 'w', encoding='utf-8') as f: f.write(final_content) ``` +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding. + ## Contributing Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. From 38d9852d364c136032d81a0bbd4a0a6b5e9814c2 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 01:16:41 +0000 Subject: [PATCH 53/73] Abbreviated project docs in llms.txt --- docs/static/llms.txt | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/static/llms.txt b/docs/static/llms.txt index e69de29..7d17f64 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -0,0 +1,75 @@ +# Project Architecture +- Keep GET routes in main.py and POST routes in routers/ directory +- Name GET routes using read_ convention +- Follow Post-Redirect-Get (PRG) pattern for all form submissions +- Use Jinja2 HTML templates for server-side rendering and minimize client-side JavaScript +- Use forms for all POST routes +- Validate form data comprehensively on the client side as first line of defense, with server-side Pydantic validation as fallback + +# File Structure +- main.py: Application entry point and GET routes +- routers/: POST route modules +- templates/: Jinja2 templates +- static/: Static assets +- tests/: Unit tests +- utils/: Helper functions and models +- docker-compose.yml: Test database configuration +- .env: Environment variables + +# Python/FastAPI Guidelines +- For all POST routes, define request models in a separate section at the top of the router file +- Implement as_form() classmethod for all form-handling request models +- Use Pydantic for request/response models with @field_validator and custom exceptions for custom form validation +- Use middleware defined in main.py for centralized exception handling +- Add type hints to all function signatures and variables +- Follow mypy type checking standards rigorously + +# Form Validation Strategy +- Implement thorough client-side validation via HTML pattern attributes where possible and Javascript otherwise +- Use Pydantic models with custom validators as server-side fallback +- Handle validation errors through middleware exception handlers +- Render validation_error.html template for failed server-side validation + +# Database Operations +- Use SQLModel for all database interactions +- Use get_session() from utils/db.py for database connections +- Define database relational models explicitly in utils/models.py +- Inject database session as dependency in route handlers + +# Authentication System +- JWT-based token authentication with separate access/refresh tokens and bcrypt for password hashing are defined in utils/auth.py +- Password and email reset tokens with expiration and password reset email flow powered by Resend are defined in utils/auth.py +- HTTP-only cookies are implemented with secure flag and SameSite=strict +- Inject common_authenticated_parameters as a dependency in all authenticated GET routes +- Inject common_unauthenticated_parameters as a dependency in all unauthenticated GET routes +- Inject get_session as a dependency in all POST routes +- Handle security-related errors without leaking information + +# Testing +- Run mypy type checking before committing code +- Write comprehensive unit tests using pytest +- Test both success and error cases +- Use test fixtures from tests/conftest.py: engine, session, client, test_user +- set_up_database and clean_db fixtures are autoused by pytest to ensure clean database state + +# Error Handling +- Use middleware for centralized exception handling +- Define custom exception classes for specific error cases +- Return appropriate HTTP status codes and error messages +- Render error templates with context data +- Log errors with "uvicorn.error" logger + +# Template Structure +- Extend base.html for consistent layout +- Use block tags for content sections +- Include reusable components +- Pass request object and context data to all templates +- Keep form validation logic in corresponding templates +- Use Bootstrap for styling + +# Contributing Guidelines +- Follow existing code style and patterns +- Preserve existing comments and docstrings +- Ensure all tests pass before submitting PR +- Update .qmd documentation files for significant changes +- Use Poetry for dependency management \ No newline at end of file From 67bf68d85cda75e5b630dae5c1563fe3662bfe80 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 01:22:05 +0000 Subject: [PATCH 54/73] Re-render of documentation.txt --- docs/static/documentation.txt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index 0635d39..09020b6 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -129,8 +129,6 @@ mypy . ## Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. - ``` {python} #| echo: false #| include: false @@ -189,11 +187,15 @@ output_dir = base_dir / 'docs' / 'static' output_dir.mkdir(parents=True, exist_ok=True) # Write the concatenated content to the output file -output_path = output_dir / 'llms.txt' +output_path = output_dir / 'documentation.txt' with open(output_path, 'w', encoding='utf-8') as f: f.write(final_content) ``` +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding. + ## Contributing Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. @@ -690,7 +692,9 @@ We find that mypy is an enormous time-saver, catching many errors early and grea ### Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have exposed the full Markdown-formatted project documentation as a [single text file](https://promptlytechnologies.com/docs/static/llms.txt) to make it more usable by LLM agents. +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). + +We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding. ## Project structure From 7fcedf9a2954e3e0fc89a955585df6e8ec26d063 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 01:27:48 +0000 Subject: [PATCH 55/73] Minor typo/wording fixes --- docs/customization.qmd | 6 ++++-- index.qmd | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/customization.qmd b/docs/customization.qmd index 463b5c2..6c59fe3 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -48,9 +48,11 @@ We find that mypy is an enormous time-saver, catching many errors early and grea ### Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a text file: [llms.txt](static/llms.txt). -We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding. +One use case for this file, if using the Cursor IDE, is to rename it to `.cursorrules` and place it in your project directory (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). Alternatively, you could use it as a custom system prompt in the web interface for ChatGPT, Claude, or the LLM of your choice. + +We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding for RAG workflows. ## Project structure diff --git a/index.qmd b/index.qmd index 93ebeef..b8a8eb9 100644 --- a/index.qmd +++ b/index.qmd @@ -194,9 +194,11 @@ with open(output_path, 'w', encoding='utf-8') as f: f.write(final_content) ``` -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a text file: [llms.txt](docs/static/llms.txt). -We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding. +One use case for this file, if using the Cursor IDE, is to rename it to `.cursorrules` and place it in your project directory (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). Alternatively, you could use it as a custom system prompt in the web interface for ChatGPT, Claude, or the LLM of your choice. + +We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG workflows. ## Contributing From 2ff7be3bcf818c6427f67441c08c8182c561cd82 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 02:01:37 +0000 Subject: [PATCH 56/73] Documented VSCode configuration --- docs/customization.qmd | 30 ++++++++++++++++----- docs/installation.qmd | 8 +++++- docs/static/documentation.txt | 50 +++++++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/customization.qmd b/docs/customization.qmd index 6c59fe3..1f9a439 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -15,6 +15,8 @@ The project uses Poetry to manage dependencies: - Install dependencies: `poetry install` - Update all dependencies: `poetry update` +If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. + ### Testing The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! @@ -96,7 +98,7 @@ We name our GET routes using the convention `read_`, where `` is the We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: ```python -# -- Authenticated Routes -- +# --- Authenticated Routes --- ``` Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. @@ -243,11 +245,16 @@ SQLModel is an Object-Relational Mapping (ORM) library that allows us to interac Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: - `Organization`: Represents a company or team -- `User`: Represents a user account -- `Role`: Represents a discrete set of user permissions within an organization -- `Permission`: Represents specific actions a user can perform -- `RolePermissionLink`: Maps roles to their allowed permissions -- `PasswordResetToken`: Manages password reset functionality +- `User`: Represents a user account with name, email, and avatar +- `Role`: Represents a set of permissions within an organization +- `Permission`: Represents specific actions a user can perform (defined by ValidPermissions enum) +- `PasswordResetToken`: Manages password reset functionality with expiration +- `UserPassword`: Stores hashed user passwords separately from user data + +Two additional models are used by SQLModel to manage many-to-many relationships; you generally will not need to interact with them directly: + +- `UserRoleLink`: Maps users to their roles (many-to-many relationship) +- `RolePermissionLink`: Maps roles to their permissions (many-to-many relationship) Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: @@ -297,6 +304,17 @@ async def get_users(session: Session = Depends(get_session)): The session automatically handles transaction management, ensuring that database operations are atomic and consistent. +There is also a helper method on the `User` model that checks if a user has a specific permission for a given organization. Its first argument must be a `ValidPermissions` enum value (from `utils/models.py`), and its second argument must be an `Organization` object or an `int` representing an organization ID: + +```python +permission = ValidPermissions.CREATE_ROLE +organization = session.exec(select(Organization).where(Organization.name == "Acme Inc.")).first() + +user.has_permission(permission, organization) +``` + +You should create custom `ValidPermissions` enum values for your application and validate that users have the necessary permissions before allowing them to modify organization data resources. + #### Cascade deletes Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: diff --git a/docs/installation.qmd b/docs/installation.qmd index 2014027..3a138b8 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -18,7 +18,7 @@ If you use VSCode with Docker to develop in a container, the following VSCode De } ``` -Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. +Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from `View > Command Palette`. *IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* @@ -69,6 +69,12 @@ poetry shell (Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) +### Configure IDE + +If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. + +It is also recommended to install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) and [Quarto](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) IDE extensions. + ## Install documentation dependencies manually ### Quarto CLI diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index 09020b6..34ef6a5 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -192,9 +192,11 @@ with open(output_path, 'w', encoding='utf-8') as f: f.write(final_content) ``` -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](docs/static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a text file: [llms.txt](docs/static/llms.txt). -We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding. +One use case for this file, if using the Cursor IDE, is to rename it to `.cursorrules` and place it in your project directory (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). Alternatively, you could use it as a custom system prompt in the web interface for ChatGPT, Claude, or the LLM of your choice. + +We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG workflows. ## Contributing @@ -517,7 +519,7 @@ If you use VSCode with Docker to develop in a container, the following VSCode De } ``` -Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette. +Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from `View > Command Palette`. *IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* @@ -568,6 +570,12 @@ poetry shell (Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) +### Configure IDE + +If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. + +It is also recommended to install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) and [Quarto](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) IDE extensions. + ## Install documentation dependencies manually ### Quarto CLI @@ -659,6 +667,8 @@ The project uses Poetry to manage dependencies: - Install dependencies: `poetry install` - Update all dependencies: `poetry update` +If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. + ### Testing The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! @@ -692,9 +702,11 @@ We find that mypy is an enormous time-saver, catching many errors early and grea ### Developing with LLMs -In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a [text file](static/llms.txt). One use case for this file is to rename it to `.cursorrules` and place it in your project directory is using the Cursor IDE (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). +In line with the [llms.txt standard](https://llmstxt.org/), we have provided a Markdown-formatted prompt—designed to help LLM agents understand how to work with this template—as a text file: [llms.txt](static/llms.txt). + +One use case for this file, if using the Cursor IDE, is to rename it to `.cursorrules` and place it in your project directory (see the [Cursor docs](https://docs.cursor.com/context/rules-for-ai) on this for more information). Alternatively, you could use it as a custom system prompt in the web interface for ChatGPT, Claude, or the LLM of your choice. -We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding. +We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding for RAG workflows. ## Project structure @@ -738,7 +750,7 @@ We name our GET routes using the convention `read_`, where `` is the We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this: ```python -# -- Authenticated Routes -- +# --- Authenticated Routes --- ``` Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. @@ -885,11 +897,16 @@ SQLModel is an Object-Relational Mapping (ORM) library that allows us to interac Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: - `Organization`: Represents a company or team -- `User`: Represents a user account -- `Role`: Represents a discrete set of user permissions within an organization -- `Permission`: Represents specific actions a user can perform -- `RolePermissionLink`: Maps roles to their allowed permissions -- `PasswordResetToken`: Manages password reset functionality +- `User`: Represents a user account with name, email, and avatar +- `Role`: Represents a set of permissions within an organization +- `Permission`: Represents specific actions a user can perform (defined by ValidPermissions enum) +- `PasswordResetToken`: Manages password reset functionality with expiration +- `UserPassword`: Stores hashed user passwords separately from user data + +Two additional models are used by SQLModel to manage many-to-many relationships; you generally will not need to interact with them directly: + +- `UserRoleLink`: Maps users to their roles (many-to-many relationship) +- `RolePermissionLink`: Maps roles to their permissions (many-to-many relationship) Here's an entity-relationship diagram (ERD) of the current database schema, automatically generated from our SQLModel definitions: @@ -939,6 +956,17 @@ async def get_users(session: Session = Depends(get_session)): The session automatically handles transaction management, ensuring that database operations are atomic and consistent. +There is also a helper method on the `User` model that checks if a user has a specific permission for a given organization. Its first argument must be a `ValidPermissions` enum value (from `utils/models.py`), and its second argument must be an `Organization` object or an `int` representing an organization ID: + +```python +permission = ValidPermissions.CREATE_ROLE +organization = session.exec(select(Organization).where(Organization.name == "Acme Inc.")).first() + +user.has_permission(permission, organization) +``` + +You should create custom `ValidPermissions` enum values for your application and validate that users have the necessary permissions before allowing them to modify organization data resources. + #### Cascade deletes Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: From 47ea2bcb7aec90296a6dc370e7de1f68578326c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:06:03 +0000 Subject: [PATCH 57/73] Bump pyjwt from 2.10.0 to 2.10.1 Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.10.0 to 2.10.1. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.0...2.10.1) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index f0bad51..3e9e5f4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2052,13 +2052,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.10.0" +version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" files = [ - {file = "PyJWT-2.10.0-py3-none-any.whl", hash = "sha256:543b77207db656de204372350926bed5a86201c4cbff159f623f79c7bb487a15"}, - {file = "pyjwt-2.10.0.tar.gz", hash = "sha256:7628a7eb7938959ac1b26e819a1df0fd3259505627b575e4bad6d08f76db695c"}, + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] [package.extras] @@ -3013,4 +3013,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "c7fcb0eef73f683768a13906716650b17b960ed6e5bfec4ed2b015691b517ea6" +content-hash = "2b9406820caa1e7f7514a584d3097ca0bce7254e50caa642c2b4647acde694d6" diff --git a/pyproject.toml b/pyproject.toml index c9c58ad..780ad77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ package-mode = false [tool.poetry.dependencies] python = "^3.12" sqlmodel = "^0.0.22" -pyjwt = "^2.10.0" +pyjwt = "^2.10.1" jinja2 = "^3.1.4" uvicorn = "^0.32.0" psycopg2 = "^2.9.10" From 577249978270d6bbb4135466789296a6d5d60882 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 21:26:14 +0000 Subject: [PATCH 58/73] Used Jinja2 templates to generate the reset email --- templates/emails/base_email.html | 31 +++++++++++++++++++++++++++++++ templates/emails/reset_email.html | 19 +++++++++++++++++++ utils/auth.py | 28 +++++++++++++++++++++------- 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 templates/emails/base_email.html create mode 100644 templates/emails/reset_email.html diff --git a/templates/emails/base_email.html b/templates/emails/base_email.html new file mode 100644 index 0000000..2fc0570 --- /dev/null +++ b/templates/emails/base_email.html @@ -0,0 +1,31 @@ +{% from 'components/logo.html' import render_logo %} + + + + + + + {% block email_title %}{% endblock %} + + +
+ +
+ {{ render_logo(width=40, height=40) }} + FastAPI-Jinja2-Postgres Webapp +
+ +
+ + {% block email_content %}{% endblock %} + +
+ +

+ This is an automated message, please do not reply directly to this email. + {% block email_footer %} + {% endblock %} +

+
+ + \ No newline at end of file diff --git a/templates/emails/reset_email.html b/templates/emails/reset_email.html new file mode 100644 index 0000000..e919368 --- /dev/null +++ b/templates/emails/reset_email.html @@ -0,0 +1,19 @@ +{% extends "emails/base_email.html" %} + +{% block email_title %}Password Reset{% endblock %} + +{% block email_content %} +

Password Reset Request

+

Hello,

+

We received a request to reset your password. If you didn't make this request, you can safely ignore this email.

+

+ Reset Your Password +

+

Or copy and paste this link into your browser:

+

{{ reset_url }}

+

This link will expire in 24 hours.

+{% endblock %} + +{% block email_footer %} + If you didn't request this password reset, please ignore this email or contact support if you have concerns. +{% endblock %} \ No newline at end of file diff --git a/utils/auth.py b/utils/auth.py index e7de8c5..01fc8f4 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -12,17 +12,24 @@ from bcrypt import gensalt, hashpw, checkpw from datetime import UTC, datetime, timedelta from typing import Optional +from jinja2.environment import Template +from fastapi.templating import Jinja2Templates from fastapi import Depends, Cookie, HTTPException, status from utils.db import get_session from utils.models import User, Role, PasswordResetToken load_dotenv() -logger = logging.getLogger("uvicorn.error") +resend.api_key = os.environ["RESEND_API_KEY"] + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) # --- Constants --- +templates = Jinja2Templates(directory="templates") SECRET_KEY = os.getenv("SECRET_KEY") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 @@ -294,9 +301,9 @@ def generate_password_reset_url(email: str, token: str) -> str: return f"{base_url}/auth/reset_password?email={email}&token={token}" -def send_reset_email(email: str, session: Session): +def send_reset_email(email: str, session: Session) -> None: # Check for an existing unexpired token - user = session.exec(select(User).where( + user: Optional[User] = session.exec(select(User).where( User.email == email )).first() if user: @@ -314,17 +321,24 @@ def send_reset_email(email: str, session: Session): return # Generate a new token - token = str(uuid.uuid4()) - reset_token = PasswordResetToken(user_id=user.id, token=token) + token: str = str(uuid.uuid4()) + reset_token: PasswordResetToken = PasswordResetToken( + user_id=user.id, token=token) session.add(reset_token) try: - reset_url = generate_password_reset_url(email, token) + reset_url: str = generate_password_reset_url(email, token) + + # Render the email template + template: Template = templates.get_template( + "emails/reset_email.html") + html_content: str = template.render({"reset_url": reset_url}) + params: resend.Emails.SendParams = { "from": "noreply@promptlytechnologies.com", "to": [email], "subject": "Password Reset Request", - "html": f"

Click here to reset your password.

", + "html": html_content, } sent_email: resend.Email = resend.Emails.send(params) From 010170c33dadfc40af8954889ee5e9fb95bc1634 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 21:31:14 +0000 Subject: [PATCH 59/73] Add a dummy resend key to Github workflow so tests will pass --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af360f1..918cd90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,6 +54,7 @@ jobs: echo "DB_NAME=test_db" >> $GITHUB_ENV echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV + echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV - name: Verify environment variables run: | @@ -63,7 +64,8 @@ jobs: [ -n "$DB_HOST" ] && \ [ -n "$DB_PORT" ] && \ [ -n "$DB_NAME" ] && \ - [ -n "$SECRET_KEY" ] + [ -n "$SECRET_KEY" ] && \ + [ -n "$RESEND_API_KEY" ] - name: Run type checking with mypy run: poetry run mypy . From e763843d700fef91cbcc9321da4e70e85e0e6702 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Mon, 2 Dec 2024 22:10:20 +0000 Subject: [PATCH 60/73] Corrected URL encoding in the email template and updated unit tests and documentation --- docs/customization.qmd | 10 ++++++++++ docs/static/documentation.txt | 10 ++++++++++ docs/static/reset_email.png | Bin 0 -> 56750 bytes tests/test_authentication.py | 6 ++++-- 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 docs/static/reset_email.png diff --git a/docs/customization.qmd b/docs/customization.qmd index 1f9a439..9cebc65 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -138,6 +138,16 @@ Best practices dictate implementing thorough client-side validation via JavaScri Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. +#### Email templating + +Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling. + +Here's how the default password reset email template looks: + +![Default Password Reset Email Template](static/reset_email.png) + +The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template). + ### Writing type annotated code Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index 34ef6a5..2669ef8 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -790,6 +790,16 @@ Best practices dictate implementing thorough client-side validation via JavaScri Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. +#### Email templating + +Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling. + +Here's how the default password reset email template looks: + +![Default Password Reset Email Template](static/reset_email.png) + +The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template). + ### Writing type annotated code Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. diff --git a/docs/static/reset_email.png b/docs/static/reset_email.png new file mode 100644 index 0000000000000000000000000000000000000000..b93623bc4f5956ab847f3b14e2e0d78e0b5f257b GIT binary patch literal 56750 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%0Aj>>pwXg^_gqlVqFp9Vx@hFYO{#O6->A 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�Jw*carNTE6nLE%+B_8aU~@0@5%Bbab2+6F9cML< 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 EA2ZpaU;qFB literal 0 HcmV?d00001 diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 58bcc04..3f76c79 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -6,6 +6,7 @@ from unittest.mock import patch import resend from urllib.parse import urlparse, parse_qs +from html import unescape from main import app from utils.models import User, PasswordResetToken @@ -321,12 +322,13 @@ def test_password_reset_email_url(unauth_client: TestClient, session: Session, t mock_resend_send.assert_called_once() call_args = mock_resend_send.call_args[0][0] html_content = call_args["html"] + print(html_content) # Extract URL from HTML import re - url_match = re.search(r'href=[\'"]([^\'"]*)[\'"]', html_content) + url_match = re.search(r']*href=[\'"]([^\'"]*)[\'"]', html_content) assert url_match is not None - reset_url = url_match.group(1) + reset_url = unescape(url_match.group(1)) # Parse and verify the URL parsed = urlparse(reset_url) From 01025828998b7a9be353edd1e70b5a7a94f2eed5 Mon Sep 17 00:00:00 2001 From: Christopher Carroll Smith Date: Tue, 3 Dec 2024 20:32:39 +0000 Subject: [PATCH 61/73] Avatar upload + dedicated endpoint for serving binary images from database --- routers/user.py | 63 ++++++++++++++++++++++++++++++++---- templates/users/profile.html | 56 +++++++++++++++++++++++++++++--- utils/models.py | 5 ++- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/routers/user.py b/routers/user.py index 5639d79..8c3a247 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException, Form -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File +from fastapi.responses import RedirectResponse, Response from pydantic import BaseModel, EmailStr -from sqlmodel import Session +from sqlmodel import Session, select +from typing import Optional from utils.models import User from utils.auth import get_session, get_authenticated_user, verify_password @@ -15,16 +16,33 @@ class UpdateProfile(BaseModel): """Request model for updating user profile information""" name: str email: EmailStr - avatar_url: str + avatar_url: Optional[str] = None + avatar_file: Optional[bytes] = None + avatar_content_type: Optional[str] = None @classmethod async def as_form( cls, name: str = Form(...), email: EmailStr = Form(...), - avatar_url: str = Form(...), + avatar_url: Optional[str] = Form(None), + avatar_file: Optional[UploadFile] = File(None), ): - return cls(name=name, email=email, avatar_url=avatar_url) + avatar_data = None + avatar_content_type = None + + if avatar_file: + # Read the file content + avatar_data = await avatar_file.read() + avatar_content_type = avatar_file.content_type + + return cls( + name=name, + email=email, + avatar_url=avatar_url if not avatar_file else None, + avatar_file=avatar_data, + avatar_content_type=avatar_content_type + ) class UserDeleteAccount(BaseModel): @@ -50,7 +68,17 @@ async def update_profile( # Update user details current_user.name = user_profile.name current_user.email = user_profile.email - current_user.avatar_url = user_profile.avatar_url + + # Handle avatar update + if user_profile.avatar_file: + current_user.avatar_url = None + current_user.avatar_data = user_profile.avatar_file + current_user.avatar_content_type = user_profile.avatar_content_type + else: + current_user.avatar_url = user_profile.avatar_url + current_user.avatar_data = None + current_user.avatar_content_type = None + session.commit() session.refresh(current_user) return RedirectResponse(url="/profile", status_code=303) @@ -84,3 +112,24 @@ async def delete_account( # Log out the user return RedirectResponse(url="/auth/logout", status_code=303) + + +@router.get("/avatar/{user_id}") +async def get_avatar( + user_id: int, + session: Session = Depends(get_session) +): + """Serve avatar image from database""" + user = session.exec(select(User).where(User.id == user_id)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.avatar_data: + return Response( + content=user.avatar_data, + media_type=user.avatar_content_type + ) + elif user.avatar_url: + return RedirectResponse(url=user.avatar_url) + else: + raise HTTPException(status_code=404, detail="Avatar not found") diff --git a/templates/users/profile.html b/templates/users/profile.html index 0053993..b51d582 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -18,8 +18,8 @@

User Profile

Email: {{ user.email }}

- {% if user.avatar_url %} - User Avatar + {% if user.avatar_url or user.avatar_data %} + User Avatar {% else %} {{ render_silhouette(width=150, height=150) }} {% endif %} @@ -35,7 +35,7 @@

User Profile

Edit Profile
- +
@@ -45,8 +45,20 @@

User Profile

- - + +
+ + +
+
+ +
+
@@ -103,5 +115,39 @@

User Profile

editProfile.style.display = 'block'; } } + + // Function to toggle between URL and file upload inputs + function toggleAvatarInput() { + var avatarType = document.getElementById('avatar_type').value; + var urlInput = document.getElementById('url_input'); + var fileInput = document.getElementById('file_input'); + var urlField = document.getElementById('avatar_url'); + + if (avatarType === 'url') { + urlInput.style.display = 'block'; + fileInput.style.display = 'none'; + // Clear file input + document.getElementById('avatar_file').value = ''; + } else { + urlInput.style.display = 'none'; + fileInput.style.display = 'block'; + // Clear URL input + urlField.value = ''; + } + } + + // Add form submission validation + document.querySelector('form[action="{{ url_for("update_profile") }}"]').addEventListener('submit', function(e) { + var avatarType = document.getElementById('avatar_type').value; + var urlField = document.getElementById('avatar_url'); + var fileField = document.getElementById('avatar_file'); + + // Clear the unused field before submission + if (avatarType === 'url') { + fileField.value = ''; + } else { + urlField.value = ''; + } + }); {% endblock %} diff --git a/utils/models.py b/utils/models.py index 63d9ec2..ef53aef 100644 --- a/utils/models.py +++ b/utils/models.py @@ -5,7 +5,7 @@ from typing import Optional, List, Union from fastapi import HTTPException from sqlmodel import SQLModel, Field, Relationship -from sqlalchemy import Column, Enum as SQLAlchemyEnum +from sqlalchemy import Column, Enum as SQLAlchemyEnum, LargeBinary from sqlalchemy.orm import Mapped logger = getLogger("uvicorn.error") @@ -181,6 +181,9 @@ class User(SQLModel, table=True): name: str email: str = Field(index=True, unique=True) avatar_url: Optional[str] = None + avatar_data: Optional[bytes] = Field( + default=None, sa_column=Column(LargeBinary)) + avatar_content_type: Optional[str] = None created_at: datetime = Field(default_factory=utc_time) updated_at: datetime = Field(default_factory=utc_time) From c618c6a4d1f87b8eb0cdf449cbdc87bc8598fc3b Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Wed, 4 Dec 2024 22:42:42 -0500 Subject: [PATCH 62/73] Added note on psycopg2 installation error --- docs/installation.qmd | 2 ++ index.qmd | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/installation.qmd b/docs/installation.qmd index 3a138b8..4a1823d 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -61,6 +61,8 @@ pipx install poetry poetry install ``` +(Note: if `psycopg2` installation fails with a `ChefBuildError`, you just need to install the PostgreSQL headers first and then try again.) + 3. Activate shell ``` bash diff --git a/index.qmd b/index.qmd index b8a8eb9..03f7e63 100644 --- a/index.qmd +++ b/index.qmd @@ -87,6 +87,8 @@ pipx install poetry poetry install ``` +(Note: if `psycopg2` installation fails with a `ChefBuildError`, you just need to install the PostgreSQL headers first and then try again.) + 3. Activate shell ``` bash @@ -97,7 +99,7 @@ poetry shell ### Set environment variables -Copy .env.example to .env with `cp .env.example .env`. +Copy `.env.example` to `.env` with `cp .env.example .env`. Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. From 90626a2652131384b2e343b413a6929518eb51d0 Mon Sep 17 00:00:00 2001 From: Akanshu Srivastav <30063953+AkanshuSrivastav@users.noreply.github.com> Date: Fri, 13 Dec 2024 06:56:14 +0000 Subject: [PATCH 63/73] Generated tests for organization and role using pytest. --- tests/test_organization.py | 61 ++++++++++++++++++++++++++++++++++++++ tests/test_role.py | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/test_organization.py create mode 100644 tests/test_role.py diff --git a/tests/test_organization.py b/tests/test_organization.py new file mode 100644 index 0000000..ccd9ccf --- /dev/null +++ b/tests/test_organization.py @@ -0,0 +1,61 @@ +# test_organization.py + +import pytest +from utils.models import Organization + +def test_organization_creation(): + """Test basic organization creation""" + org = Organization("Test Org", "Test Description") + assert org.name == "Test Org" + assert org.description == "Test Description" + assert org.roles == [] + assert org.members == [] + +def test_organization_add_role(): + """Test adding a role to organization""" + org = Organization("Test Org", "Test Description") + role = {"name": "Admin", "permissions": ["read", "write"]} + org.add_role(role) + assert len(org.roles) == 1 + assert org.roles[0] == role + +def test_organization_add_member(): + """Test adding a member to organization""" + org = Organization("Test Org", "Test Description") + member = {"id": 1, "name": "John Doe"} + org.add_member(member) + assert len(org.members) == 1 + assert org.members[0] == member + +def test_organization_remove_member(): + """Test removing a member from organization""" + org = Organization("Test Org", "Test Description") + member = {"id": 1, "name": "John Doe"} + org.add_member(member) + org.remove_member(1) + assert len(org.members) == 0 + +def test_organization_get_member(): + """Test getting a member from organization""" + org = Organization("Test Org", "Test Description") + member = {"id": 1, "name": "John Doe"} + org.add_member(member) + retrieved_member = org.get_member(1) + assert retrieved_member == member + +def test_organization_get_nonexistent_member(): + """Test getting a non-existent member""" + org = Organization("Test Org", "Test Description") + assert org.get_member(1) is None + +# Additional tests for organization.py + +def test_organization_invalid_name(): + """Test organization creation with invalid name""" + with pytest.raises(ValueError): + Organization("", "Description") + +def test_organization_none_name(): + """Test organization creation with None name""" + with pytest.raises(ValueError): + Organization(None, "Description") diff --git a/tests/test_role.py b/tests/test_role.py new file mode 100644 index 0000000..a934212 --- /dev/null +++ b/tests/test_role.py @@ -0,0 +1,60 @@ +# test_role.py + +import pytest +from utils.models import Role + +def test_role_creation(): + """Test basic role creation""" + role = Role("Admin", ["read", "write"]) + assert role.name == "Admin" + assert role.permissions == ["read", "write"] + +def test_role_add_permission(): + """Test adding a permission to role""" + role = Role("Admin", ["read"]) + role.add_permission("write") + assert "write" in role.permissions + assert len(role.permissions) == 2 + +def test_role_remove_permission(): + """Test removing a permission from role""" + role = Role("Admin", ["read", "write"]) + role.remove_permission("write") + assert "write" not in role.permissions + assert len(role.permissions) == 1 + +def test_role_has_permission(): + """Test checking if role has specific permission""" + role = Role("Admin", ["read", "write"]) + assert role.has_permission("read") is True + assert role.has_permission("delete") is False + +def test_role_add_existing_permission(): + """Test adding a permission that already exists""" + role = Role("Admin", ["read"]) + role.add_permission("read") + assert len(role.permissions) == 1 + +def test_role_remove_nonexistent_permission(): + """Test removing a permission that doesn't exist""" + role = Role("Admin", ["read"]) + role.remove_permission("write") + assert len(role.permissions) == 1 + assert role.permissions == ["read"] + +# Additional tests for role.py + +def test_role_invalid_name(): + """Test role creation with invalid name""" + with pytest.raises(ValueError): + Role("", ["read"]) + +def test_role_none_permissions(): + """Test role creation with None permissions""" + with pytest.raises(ValueError): + Role("Admin", None) + +def test_role_empty_permissions(): + """Test role creation with empty permissions list""" + role = Role("Admin", []) + assert len(role.permissions) == 0 \ No newline at end of file From 56b4b3c974a9f9997ef390756a0a7ebc5e3059a8 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Fri, 13 Dec 2024 17:48:05 -0500 Subject: [PATCH 64/73] Adjusted test suite and fixed list access mistake in read_organization --- main.py | 5 +- tests/test_organization.py | 125 ++++++++++++++++++++----------------- 2 files changed, 70 insertions(+), 60 deletions(-) diff --git a/main.py b/main.py index 82e1cc4..f67391b 100644 --- a/main.py +++ b/main.py @@ -255,7 +255,10 @@ async def read_organization( params: dict = Depends(common_authenticated_parameters) ): # Get the organization only if the user is a member of it - org: Organization = params["user"].organizations.get(org_id) + org = next( + (org for org in params["user"].organizations if org.id == org_id), + None + ) if not org: raise organization.OrganizationNotFoundError() diff --git a/tests/test_organization.py b/tests/test_organization.py index ccd9ccf..978c019 100644 --- a/tests/test_organization.py +++ b/tests/test_organization.py @@ -1,61 +1,68 @@ # test_organization.py -import pytest -from utils.models import Organization - -def test_organization_creation(): - """Test basic organization creation""" - org = Organization("Test Org", "Test Description") - assert org.name == "Test Org" - assert org.description == "Test Description" - assert org.roles == [] - assert org.members == [] - -def test_organization_add_role(): - """Test adding a role to organization""" - org = Organization("Test Org", "Test Description") - role = {"name": "Admin", "permissions": ["read", "write"]} - org.add_role(role) - assert len(org.roles) == 1 - assert org.roles[0] == role - -def test_organization_add_member(): - """Test adding a member to organization""" - org = Organization("Test Org", "Test Description") - member = {"id": 1, "name": "John Doe"} - org.add_member(member) - assert len(org.members) == 1 - assert org.members[0] == member - -def test_organization_remove_member(): - """Test removing a member from organization""" - org = Organization("Test Org", "Test Description") - member = {"id": 1, "name": "John Doe"} - org.add_member(member) - org.remove_member(1) - assert len(org.members) == 0 - -def test_organization_get_member(): - """Test getting a member from organization""" - org = Organization("Test Org", "Test Description") - member = {"id": 1, "name": "John Doe"} - org.add_member(member) - retrieved_member = org.get_member(1) - assert retrieved_member == member - -def test_organization_get_nonexistent_member(): - """Test getting a non-existent member""" - org = Organization("Test Org", "Test Description") - assert org.get_member(1) is None - -# Additional tests for organization.py - -def test_organization_invalid_name(): - """Test organization creation with invalid name""" - with pytest.raises(ValueError): - Organization("", "Description") - -def test_organization_none_name(): - """Test organization creation with None name""" - with pytest.raises(ValueError): - Organization(None, "Description") +from utils.models import Organization, Role +from sqlmodel import select + +def test_create_organization_success(auth_client, session, test_user): + """Test successful organization creation""" + response = auth_client.post( + "/organizations/create", + data={"name": "New Test Organization"}, + follow_redirects=False + ) + + # Check response + assert response.status_code == 303 # Redirect status code + assert "/organizations/" in response.headers["location"] + + # Verify database state + org = session.exec( + select(Organization) + .where(Organization.name == "New Test Organization") + ).first() + + assert org is not None + assert org.name == "New Test Organization" + + # Verify default roles were created + roles = session.exec( + select(Role) + .where(Role.organization_id == org.id) + ).all() + + assert len(roles) > 0 + assert any(role.name == "Owner" for role in roles) + + # Verify test_user was assigned as owner + owner_role = next(role for role in roles if role.name == "Owner") + assert test_user in owner_role.users + +def test_create_organization_empty_name(auth_client): + """Test organization creation with empty name""" + response = auth_client.post( + "/organizations/create", + data={"name": " "} # Empty or whitespace name + ) + + assert response.status_code == 400 + assert "Organization name cannot be empty" in response.text + +def test_create_organization_duplicate_name(auth_client, test_organization): + """Test organization creation with duplicate name""" + response = auth_client.post( + "/organizations/create", + data={"name": test_organization.name} + ) + + assert response.status_code == 400 + assert "Organization name already taken" in response.text + +def test_create_organization_unauthenticated(unauth_client): + """Test organization creation without authentication""" + response = unauth_client.post( + "/organizations/create", + data={"name": "Unauthorized Org"}, + follow_redirects=False + ) + + assert response.status_code == 303 # Unauthorized From 067981093936d940e793564b6816a4ec8a6c4c97 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Fri, 13 Dec 2024 18:02:29 -0500 Subject: [PATCH 65/73] Basic tests of create_role endpoint --- tests/test_role.py | 159 +++++++++++++++++++++++++++++---------------- 1 file changed, 102 insertions(+), 57 deletions(-) diff --git a/tests/test_role.py b/tests/test_role.py index a934212..db4e58d 100644 --- a/tests/test_role.py +++ b/tests/test_role.py @@ -1,60 +1,105 @@ # test_role.py import pytest -from utils.models import Role - -def test_role_creation(): - """Test basic role creation""" - role = Role("Admin", ["read", "write"]) - assert role.name == "Admin" - assert role.permissions == ["read", "write"] - -def test_role_add_permission(): - """Test adding a permission to role""" - role = Role("Admin", ["read"]) - role.add_permission("write") - assert "write" in role.permissions - assert len(role.permissions) == 2 - -def test_role_remove_permission(): - """Test removing a permission from role""" - role = Role("Admin", ["read", "write"]) - role.remove_permission("write") - assert "write" not in role.permissions - assert len(role.permissions) == 1 - -def test_role_has_permission(): - """Test checking if role has specific permission""" - role = Role("Admin", ["read", "write"]) - assert role.has_permission("read") is True - assert role.has_permission("delete") is False - -def test_role_add_existing_permission(): - """Test adding a permission that already exists""" - role = Role("Admin", ["read"]) - role.add_permission("read") - assert len(role.permissions) == 1 - -def test_role_remove_nonexistent_permission(): - """Test removing a permission that doesn't exist""" - role = Role("Admin", ["read"]) - role.remove_permission("write") - assert len(role.permissions) == 1 - assert role.permissions == ["read"] - -# Additional tests for role.py - -def test_role_invalid_name(): - """Test role creation with invalid name""" - with pytest.raises(ValueError): - Role("", ["read"]) - -def test_role_none_permissions(): - """Test role creation with None permissions""" - with pytest.raises(ValueError): - Role("Admin", None) - -def test_role_empty_permissions(): - """Test role creation with empty permissions list""" - role = Role("Admin", []) - assert len(role.permissions) == 0 \ No newline at end of file +from utils.models import Role, Permission, ValidPermissions, User +from sqlmodel import Session, select + + +@pytest.fixture +def admin_user(session: Session, test_user: User, test_organization): + """Create an admin user with CREATE_ROLE permission""" + admin_role = Role( + name="Admin", + organization_id=test_organization.id + ) + create_role_permission = session.exec( + select(Permission).where(Permission.name == ValidPermissions.CREATE_ROLE) + ).first() + admin_role.permissions.append(create_role_permission) + session.add(admin_role) + + test_user.roles.append(admin_role) + session.commit() + return test_user + + +def test_create_role_success(auth_client, admin_user, test_organization, session: Session): + """Test successful role creation""" + response = auth_client.post( + "/roles/create", + data={ + "name": "Test Role", + "organization_id": test_organization.id, + "permissions": [ValidPermissions.EDIT_ROLE.value] + }, + follow_redirects=False + ) + + assert response.status_code == 303 + + # Verify role was created in database + created_role = session.exec( + select(Role).where( + Role.name == "Test Role", + Role.organization_id == test_organization.id + ) + ).first() + + assert created_role is not None + assert created_role.name == "Test Role" + assert len(created_role.permissions) == 1 + assert created_role.permissions[0].name == ValidPermissions.EDIT_ROLE + + +def test_create_role_unauthorized(auth_client, test_user, test_organization): + """Test role creation without proper permissions""" + response = auth_client.post( + "/roles/create", + data={ + "name": "Test Role", + "organization_id": test_organization.id, + "permissions": [ValidPermissions.EDIT_ROLE.value] + }, + follow_redirects=False + ) + + assert response.status_code == 403 + + +def test_create_duplicate_role(auth_client, admin_user, test_organization, session: Session): + """Test creating a role with a name that already exists in the organization""" + # Create initial role + existing_role = Role( + name="Existing Role", + organization_id=test_organization.id + ) + session.add(existing_role) + session.commit() + + # Attempt to create role with same name + response = auth_client.post( + "/roles/create", + data={ + "name": "Existing Role", + "organization_id": test_organization.id, + "permissions": [ValidPermissions.EDIT_ROLE.value] + }, + follow_redirects=False + ) + + assert response.status_code == 400 + + +def test_create_role_unauthenticated(unauth_client, test_organization): + """Test role creation without authentication""" + response = unauth_client.post( + "/roles/create", + data={ + "name": "Test Role", + "organization_id": test_organization.id, + "permissions": [ValidPermissions.EDIT_ROLE.value] + }, + follow_redirects=False + ) + + assert response.status_code == 303 From 8a3ff74e224f4cd42047fea1458298a10c4f2519 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sun, 15 Dec 2024 10:31:21 -0500 Subject: [PATCH 66/73] Fixed type lint error --- tests/test_role.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/test_role.py b/tests/test_role.py index db4e58d..e44421b 100644 --- a/tests/test_role.py +++ b/tests/test_role.py @@ -8,18 +8,24 @@ @pytest.fixture def admin_user(session: Session, test_user: User, test_organization): """Create an admin user with CREATE_ROLE permission""" - admin_role = Role( + admin_role: Role = Role( name="Admin", organization_id=test_organization.id ) - create_role_permission = session.exec( + + create_role_permission: Permission | None = session.exec( select(Permission).where(Permission.name == ValidPermissions.CREATE_ROLE) ).first() + + if create_role_permission is None: + raise ValueError("Error during test setup: CREATE_ROLE permission not found") + admin_role.permissions.append(create_role_permission) session.add(admin_role) - + test_user.roles.append(admin_role) session.commit() + return test_user @@ -34,9 +40,9 @@ def test_create_role_success(auth_client, admin_user, test_organization, session }, follow_redirects=False ) - + assert response.status_code == 303 - + # Verify role was created in database created_role = session.exec( select(Role).where( @@ -44,7 +50,7 @@ def test_create_role_success(auth_client, admin_user, test_organization, session Role.organization_id == test_organization.id ) ).first() - + assert created_role is not None assert created_role.name == "Test Role" assert len(created_role.permissions) == 1 @@ -62,7 +68,7 @@ def test_create_role_unauthorized(auth_client, test_user, test_organization): }, follow_redirects=False ) - + assert response.status_code == 403 @@ -75,7 +81,7 @@ def test_create_duplicate_role(auth_client, admin_user, test_organization, sessi ) session.add(existing_role) session.commit() - + # Attempt to create role with same name response = auth_client.post( "/roles/create", @@ -86,7 +92,7 @@ def test_create_duplicate_role(auth_client, admin_user, test_organization, sessi }, follow_redirects=False ) - + assert response.status_code == 400 @@ -101,5 +107,5 @@ def test_create_role_unauthenticated(unauth_client, test_organization): }, follow_redirects=False ) - + assert response.status_code == 303 From fa3fd958928dbf64812589037efd02e3c67de844 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sun, 15 Dec 2024 14:09:43 -0500 Subject: [PATCH 67/73] Migrated from poetry to uv for dep mgmt --- .github/workflows/test.yml | 16 +- README.md | 85 +- docs/customization.qmd | 23 +- docs/installation.qmd | 57 +- docs/static/auth_flow.png | Bin 52432 -> 64291 bytes docs/static/data_flow.png | Bin 75952 -> 81772 bytes docs/static/documentation.txt | 131 +- docs/static/llms.txt | 2 +- docs/static/reset_flow.png | Bin 52274 -> 55868 bytes docs/static/schema.png | Bin 37008 -> 49687 bytes index.qmd | 51 +- main.py | 8 +- poetry.lock | 3016 --------------------------------- pyproject.toml | 57 +- routers/authentication.py | 6 +- routers/organization.py | 6 +- routers/role.py | 6 +- routers/user.py | 4 +- uv.lock | 1891 +++++++++++++++++++++ 19 files changed, 2174 insertions(+), 3185 deletions(-) delete mode 100644 poetry.lock create mode 100644 uv.lock diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 918cd90..5a6172d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,6 @@ jobs: fail-fast: false matrix: python-version: ["3.12"] - poetry-version: [latest] os: [ubuntu-latest] runs-on: ${{ matrix.os }} @@ -35,15 +34,16 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install and configure Poetry - uses: snok/install-poetry@v1 - - name: Install project - run: poetry install + run: uv sync --all-extras --dev - name: Set env variables for pytest run: | @@ -68,7 +68,7 @@ jobs: [ -n "$RESEND_API_KEY" ] - name: Run type checking with mypy - run: poetry run mypy . + run: uv run mypy . - name: Run tests with pytest - run: poetry run pytest -s tests/ + run: uv run pytest tests/ diff --git a/README.md b/README.md index dd54732..419edde 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ to deploy to any major cloud hosting platform. **Additional technologies:** -- [Poetry](https://python-poetry.org/): Python dependency manager +- [uv](https://docs.astral.sh/uv/): Python dependency manager - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization - [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline @@ -72,56 +72,73 @@ to deploy to any major cloud hosting platform. For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). -### Python and Docker +### uv -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) +MacOS and Linux: -### PostgreSQL headers +``` bash +wget -qO- https://astral.sh/uv/install.sh | sh +``` -For Ubuntu/Debian: +Windows: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` -For macOS: +See the [uv installation +docs](https://docs.astral.sh/uv/getting-started/installation/) for more +information. + +### Python + +Install Python 3.12 or higher from either the official [downloads +page](https://www.python.org/downloads/) or using uv: ``` bash -brew install postgresql +# Installs the latest version +uv python install ``` -For Windows: +### Docker and Docker Compose -- No installation required +Install Docker Desktop and Coker Compose for your operating system by +following the [instructions in the +documentation](https://docs.docker.com/compose/install/). -### Python dependencies +### PostgreSQL headers -1. Install Poetry +For Ubuntu/Debian: ``` bash -pipx install poetry +sudo apt update && sudo apt install -y python3-dev libpq-dev ``` -2. Install project dependencies +For macOS: ``` bash -poetry install +brew install postgresql ``` -3. Activate shell +For Windows: + +- No installation required + +### Python dependencies + +From the root directory, run: ``` bash -poetry shell +uv venv +uv sync ``` -(Note: You will need to activate the shell every time you open a new -terminal session. Alternatively, you can use the `poetry run` prefix -before other commands to run them without activating the shell.) +This will create an in-project virtual environment and install all +dependencies. ### Set environment variables -Copy .env.example to .env with `cp .env.example .env`. +Copy `.env.example` to `.env` with `cp .env.example .env`. Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. @@ -134,6 +151,9 @@ account, verify a domain, get an API key, and paste the API key into the ### Start development database +To start the development database, run the following command in your +terminal from the root directory: + ``` bash docker compose up -d ``` @@ -155,14 +175,31 @@ Navigate to http://localhost:8000/ mypy . ``` -### Contributing +## Developing with LLMs + +In line with the [llms.txt standard](https://llmstxt.org/), we have +provided a Markdown-formatted prompt—designed to help LLM agents +understand how to work with this template—as a text file: +[llms.txt](docs/static/llms.txt). + +One use case for this file, if using the Cursor IDE, is to rename it to +`.cursorrules` and place it in your project directory (see the [Cursor +docs](https://docs.cursor.com/context/rules-for-ai) on this for more +information). Alternatively, you could use it as a custom system prompt +in the web interface for ChatGPT, Claude, or the LLM of your choice. + +We have also exposed the full Markdown-formatted project documentation +as a [single text file](docs/static/documentation.txt) for easy +downloading and embedding for RAG workflows. + +## Contributing Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. -### License +## License This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT diff --git a/docs/customization.qmd b/docs/customization.qmd index 9cebc65..e02593d 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -4,18 +4,23 @@ title: "Customization" ## Development workflow -### Dependency management with Poetry +### Dependency management with `uv` -The project uses Poetry to manage dependencies: +The project uses `uv` to manage dependencies: -- Add new dependency: `poetry add ` -- Add development dependency: `poetry add --dev ` -- Remove dependency: `poetry remove ` -- Update lock file: `poetry lock` -- Install dependencies: `poetry install` -- Update all dependencies: `poetry update` +- Add new dependency: `uv add ` +- Add development dependency: `uv add --dev ` +- Remove dependency: `uv remove ` +- Update lock file: `uv lock` +- Install all dependencies: `uv sync` +- Install only production dependencies: `uv sync --no-dev` +- Upgrade dependencies: `uv lock --upgrade` -If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. +### IDE configuration + +If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`. + +If your IDE does not automatically detect and display this option, you can manually select the interpreter by selecting "Enter interpreter path" and then navigating to the `.venv/bin/python` subfolder in your project directory. ### Testing diff --git a/docs/installation.qmd b/docs/installation.qmd index 4a1823d..e85c13b 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -9,9 +9,12 @@ If you use VSCode with Docker to develop in a container, the following VSCode De ``` json { "name": "Python 3", - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && pipx install poetry && poetry install && poetry shell", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync", "features": { + "ghcr.io/va-h/devcontainers-features/uv:1": { + "version": "latest" + }, "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {} } @@ -24,10 +27,34 @@ Simply create a `.devcontainer` folder in the root of the project and add a `dev ## Install development dependencies manually -### Python and Docker +### uv -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) +MacOS and Linux: + +``` bash +wget -qO- https://astral.sh/uv/install.sh | sh +``` + +Windows: + +``` bash +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information. + +### Python + +Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv: + +``` bash +# Installs the latest version +uv python install +``` + +### Docker and Docker Compose + +Install Docker Desktop and Docker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/). ### PostgreSQL headers @@ -49,31 +76,25 @@ For Windows: ### Python dependencies -1. Install Poetry +From the root directory, run: ``` bash -pipx install poetry +uv venv ``` -2. Install project dependencies +This will create an in-project virtual environment. Then run: ``` bash -poetry install +uv sync ``` -(Note: if `psycopg2` installation fails with a `ChefBuildError`, you just need to install the PostgreSQL headers first and then try again.) - -3. Activate shell - -``` bash -poetry shell -``` +This will install all dependencies. -(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) +(Note: if `psycopg2` installation fails, you probably just need to install the PostgreSQL headers first and then try again.) ### Configure IDE -If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. +If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`. It is also recommended to install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) and [Quarto](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) IDE extensions. diff --git a/docs/static/auth_flow.png b/docs/static/auth_flow.png index 2f331be8dd3a7a55cf6bd188b18050517d4e8a96..2e49769b673d0db767fe804636da2252fa9aa49c 100644 GIT binary patch literal 64291 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
nzz-`uKar6>fBuZr(AfCt?7%2hD!vWU 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 literal 52432 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

){&#~)+~X9jh+fWp@Zb61B+w%;JJ5r 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

g@!V6Zr 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 O&mPVF8oBD$=l=uFbo}4| diff --git a/docs/static/data_flow.png b/docs/static/data_flow.png index 0297c6cc41b5d94a734547cdb73ec9f8e23f7834..5324b86d9ae8138cc0e9ff1997d05942a6e6a591 100644 GIT binary patch literal 81772 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

2VX;l+%&SVSP@ZV;^5F*Xwik$y!sUU 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
OD|Ol4I(lDjE}f<|9TFpvq9V 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 literal 75952 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 EH;*@$&j0`b diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index 2669ef8..f15677b 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -35,7 +35,7 @@ The design philosophy of the template is to prefer low-level, best-in-class open **Additional technologies:** -- [Poetry](https://python-poetry.org/): Python dependency manager +- [uv](https://docs.astral.sh/uv/): Python dependency manager - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization - [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline @@ -48,54 +48,67 @@ The design philosophy of the template is to prefer low-level, best-in-class open For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). -### Python and Docker +### uv -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) +MacOS and Linux: -### PostgreSQL headers +``` bash +wget -qO- https://astral.sh/uv/install.sh | sh +``` -For Ubuntu/Debian: +Windows: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` -For macOS: +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information. + +### Python + +Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv: ``` bash -brew install postgresql +# Installs the latest version +uv python install ``` -For Windows: +### Docker and Docker Compose -- No installation required +Install Docker Desktop and Coker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/). -### Python dependencies +### PostgreSQL headers -1. Install Poetry +For Ubuntu/Debian: ``` bash -pipx install poetry +sudo apt update && sudo apt install -y python3-dev libpq-dev ``` -2. Install project dependencies +For macOS: ``` bash -poetry install +brew install postgresql ``` -3. Activate shell +For Windows: + +- No installation required + +### Python dependencies + +From the root directory, run: ``` bash -poetry shell +uv venv +uv sync ``` -(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) +This will create an in-project virtual environment and install all dependencies. ### Set environment variables -Copy .env.example to .env with `cp .env.example .env`. +Copy `.env.example` to `.env` with `cp .env.example .env`. Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. @@ -510,9 +523,12 @@ If you use VSCode with Docker to develop in a container, the following VSCode De ``` json { "name": "Python 3", - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && pipx install poetry && poetry install && poetry shell", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync", "features": { + "ghcr.io/va-h/devcontainers-features/uv:1": { + "version": "latest" + }, "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {} } @@ -525,10 +541,34 @@ Simply create a `.devcontainer` folder in the root of the project and add a `dev ## Install development dependencies manually -### Python and Docker +### uv + +MacOS and Linux: + +``` bash +wget -qO- https://astral.sh/uv/install.sh | sh +``` + +Windows: + +``` bash +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information. -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) +### Python + +Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv: + +``` bash +# Installs the latest version +uv python install +``` + +### Docker and Docker Compose + +Install Docker Desktop and Docker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/). ### PostgreSQL headers @@ -550,29 +590,25 @@ For Windows: ### Python dependencies -1. Install Poetry +From the root directory, run: ``` bash -pipx install poetry +uv venv ``` -2. Install project dependencies +This will create an in-project virtual environment. Then run: ``` bash -poetry install +uv sync ``` -3. Activate shell +This will install all dependencies. -``` bash -poetry shell -``` - -(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) +(Note: if `psycopg2` installation fails, you probably just need to install the PostgreSQL headers first and then try again.) ### Configure IDE -If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. +If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`. It is also recommended to install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) and [Quarto](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) IDE extensions. @@ -656,18 +692,23 @@ mypy . ## Development workflow -### Dependency management with Poetry +### Dependency management with `uv` + +The project uses `uv` to manage dependencies: + +- Add new dependency: `uv add ` +- Add development dependency: `uv add --dev ` +- Remove dependency: `uv remove ` +- Update lock file: `uv lock` +- Install all dependencies: `uv sync` +- Install only production dependencies: `uv sync --no-dev` +- Upgrade dependencies: `uv lock --upgrade` -The project uses Poetry to manage dependencies: +### IDE configuration -- Add new dependency: `poetry add ` -- Add development dependency: `poetry add --dev ` -- Remove dependency: `poetry remove ` -- Update lock file: `poetry lock` -- Install dependencies: `poetry install` -- Update all dependencies: `poetry update` +If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`. -If you are using VSCode or Cursor as your IDE, you will need to select the Poetry-managed Python version as your interpreter for the project. To find the location of the Poetry-managed Python interpreter, run `poetry env info` and look for the `Path` field. Then, in VSCode, go to `View > Command Palette`, search for `Python: Select Interpreter`, and either select Poetry's Python version from the list (if it has been auto-detected) or "Enter interpreter path" manually. +If your IDE does not automatically detect and display this option, you can manually select the interpreter by selecting "Enter interpreter path" and then navigating to the `.venv/bin/python` subfolder in your project directory. ### Testing diff --git a/docs/static/llms.txt b/docs/static/llms.txt index 7d17f64..adc1692 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -72,4 +72,4 @@ - Preserve existing comments and docstrings - Ensure all tests pass before submitting PR - Update .qmd documentation files for significant changes -- Use Poetry for dependency management \ No newline at end of file +- Use uv for dependency management \ No newline at end of file diff --git a/docs/static/reset_flow.png b/docs/static/reset_flow.png index bdaccae58c6dcd1f3b0ff24d75c93ac5616e9c4a..6d47b38f9c4653de25b7836c07582c2254520bd3 100644 GIT binary patch literal 55868 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 diff --git a/docs/static/schema.png b/docs/static/schema.png index ae8fbf981d4a80b1b907a3434cfc6320523dc2b9..b47a3a19535b28c750a848c990cc1b58a4b64efe 100644 GIT binary patch literal 49687 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 b`q}W2hq`x3gC`aW{uyd9(mc*|+PeP-t{yEl literal 37008 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 TSG_&_n_{@lH`d22o!0z6nlPPZ diff --git a/index.qmd b/index.qmd index 03f7e63..2b63ad7 100644 --- a/index.qmd +++ b/index.qmd @@ -37,7 +37,7 @@ The design philosophy of the template is to prefer low-level, best-in-class open **Additional technologies:** -- [Poetry](https://python-poetry.org/): Python dependency manager +- [uv](https://docs.astral.sh/uv/): Python dependency manager - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization - [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline @@ -50,52 +50,63 @@ The design philosophy of the template is to prefer low-level, best-in-class open For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). -### Python and Docker +### uv -- [Python 3.12 or higher](https://www.python.org/downloads/) -- [Docker and Docker Compose](https://docs.docker.com/get-docker/) +MacOS and Linux: -### PostgreSQL headers +``` bash +wget -qO- https://astral.sh/uv/install.sh | sh +``` -For Ubuntu/Debian: +Windows: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` -For macOS: +See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information. + +### Python + +Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv: ``` bash -brew install postgresql +# Installs the latest version +uv python install ``` -For Windows: +### Docker and Docker Compose -- No installation required +Install Docker Desktop and Coker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/). -### Python dependencies +### PostgreSQL headers -1. Install Poetry +For Ubuntu/Debian: ``` bash -pipx install poetry +sudo apt update && sudo apt install -y python3-dev libpq-dev ``` -2. Install project dependencies +For macOS: ``` bash -poetry install +brew install postgresql ``` -(Note: if `psycopg2` installation fails with a `ChefBuildError`, you just need to install the PostgreSQL headers first and then try again.) +For Windows: + +- No installation required + +### Python dependencies -3. Activate shell +From the root directory, run: ``` bash -poetry shell +uv venv +uv sync ``` -(Note: You will need to activate the shell every time you open a new terminal session. Alternatively, you can use the `poetry run` prefix before other commands to run them without activating the shell.) +This will create an in-project virtual environment and install all dependencies. ### Set environment variables diff --git a/main.py b/main.py index f67391b..9ca359e 100644 --- a/main.py +++ b/main.py @@ -33,7 +33,7 @@ async def lifespan(app: FastAPI): templates = Jinja2Templates(directory="templates") -# -- Exception Handling Middlewares -- +# --- Exception Handling Middlewares --- # Handle AuthenticationError by redirecting to login page @@ -137,7 +137,7 @@ async def general_exception_handler(request: Request, exc: Exception): ) -# -- Unauthenticated Routes -- +# --- Unauthenticated Routes --- # Define a dependency for common parameters @@ -222,7 +222,7 @@ async def read_reset_password( return templates.TemplateResponse(params["request"], "authentication/reset_password.html", params) -# -- Authenticated Routes -- +# --- Authenticated Routes --- # Define a dependency for common parameters @@ -270,7 +270,7 @@ async def read_organization( return templates.TemplateResponse(params["request"], "users/organization.html", params) -# -- Include Routers -- +# --- Include Routers --- app.include_router(authentication.router) diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 3e9e5f4..0000000 --- a/poetry.lock +++ /dev/null @@ -1,3016 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.6.2.post1" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.9" -files = [ - {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, - {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, -] - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "appnope" -version = "0.1.4" -description = "Disable App Nap on macOS >= 10.9" -optional = false -python-versions = ">=3.6" -files = [ - {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, - {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, -] - -[[package]] -name = "argon2-cffi" -version = "23.1.0" -description = "Argon2 for Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, - {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, -] - -[package.dependencies] -argon2-cffi-bindings = "*" - -[package.extras] -dev = ["argon2-cffi[tests,typing]", "tox (>4)"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] -tests = ["hypothesis", "pytest"] -typing = ["mypy"] - -[[package]] -name = "argon2-cffi-bindings" -version = "21.2.0" -description = "Low-level CFFI bindings for Argon2" -optional = false -python-versions = ">=3.6" -files = [ - {file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"}, - {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"}, - {file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"}, - {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"}, - {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"}, - {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"}, - {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"}, - {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"}, - {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"}, - {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"}, - {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"}, - {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"}, - {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"}, -] - -[package.dependencies] -cffi = ">=1.0.1" - -[package.extras] -dev = ["cogapp", "pre-commit", "pytest", "wheel"] -tests = ["pytest"] - -[[package]] -name = "arrow" -version = "1.3.0" -description = "Better dates & times for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"}, - {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"}, -] - -[package.dependencies] -python-dateutil = ">=2.7.0" -types-python-dateutil = ">=2.8.10" - -[package.extras] -doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] -test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] - -[[package]] -name = "asttokens" -version = "2.4.1" -description = "Annotate AST trees with source code positions" -optional = false -python-versions = "*" -files = [ - {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, - {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, -] - -[package.dependencies] -six = ">=1.12.0" - -[package.extras] -astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] -test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] - -[[package]] -name = "async-lru" -version = "2.0.4" -description = "Simple LRU cache for asyncio" -optional = false -python-versions = ">=3.8" -files = [ - {file = "async-lru-2.0.4.tar.gz", hash = "sha256:b8a59a5df60805ff63220b2a0c5b5393da5521b113cd5465a44eb037d81a5627"}, - {file = "async_lru-2.0.4-py3-none-any.whl", hash = "sha256:ff02944ce3c288c5be660c42dbcca0742b32c3b279d6dceda655190240b99224"}, -] - -[[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, -] - -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - -[[package]] -name = "babel" -version = "2.16.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.8" -files = [ - {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, - {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, -] - -[package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] - -[[package]] -name = "bcrypt" -version = "4.2.0" -description = "Modern password hashing for your software and your servers" -optional = false -python-versions = ">=3.7" -files = [ - {file = "bcrypt-4.2.0-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb"}, - {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00"}, - {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d"}, - {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291"}, - {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328"}, - {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7"}, - {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399"}, - {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060"}, - {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7"}, - {file = "bcrypt-4.2.0-cp37-abi3-win32.whl", hash = "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458"}, - {file = "bcrypt-4.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5"}, - {file = "bcrypt-4.2.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841"}, - {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68"}, - {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe"}, - {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2"}, - {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c"}, - {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae"}, - {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d"}, - {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e"}, - {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8"}, - {file = "bcrypt-4.2.0-cp39-abi3-win32.whl", hash = "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34"}, - {file = "bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9"}, - {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a"}, - {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db"}, - {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170"}, - {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184"}, - {file = "bcrypt-4.2.0.tar.gz", hash = "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221"}, -] - -[package.extras] -tests = ["pytest (>=3.2.1,!=3.3.0)"] -typecheck = ["mypy"] - -[[package]] -name = "beautifulsoup4" -version = "4.12.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "bleach" -version = "6.2.0" -description = "An easy safelist-based HTML-sanitizing tool." -optional = false -python-versions = ">=3.9" -files = [ - {file = "bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e"}, - {file = "bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f"}, -] - -[package.dependencies] -webencodings = "*" - -[package.extras] -css = ["tinycss2 (>=1.1.0,<1.5)"] - -[[package]] -name = "certifi" -version = "2024.8.30" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, -] - -[[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "charset-normalizer" -version = "3.4.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[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" -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 = "comm" -version = "0.2.2" -description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." -optional = false -python-versions = ">=3.8" -files = [ - {file = "comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3"}, - {file = "comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e"}, -] - -[package.dependencies] -traitlets = ">=4" - -[package.extras] -test = ["pytest"] - -[[package]] -name = "debugpy" -version = "1.8.8" -description = "An implementation of the Debug Adapter Protocol for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "debugpy-1.8.8-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:e59b1607c51b71545cb3496876544f7186a7a27c00b436a62f285603cc68d1c6"}, - {file = "debugpy-1.8.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6531d952b565b7cb2fbd1ef5df3d333cf160b44f37547a4e7cf73666aca5d8d"}, - {file = "debugpy-1.8.8-cp310-cp310-win32.whl", hash = "sha256:b01f4a5e5c5fb1d34f4ccba99a20ed01eabc45a4684f4948b5db17a319dfb23f"}, - {file = "debugpy-1.8.8-cp310-cp310-win_amd64.whl", hash = "sha256:535f4fb1c024ddca5913bb0eb17880c8f24ba28aa2c225059db145ee557035e9"}, - {file = "debugpy-1.8.8-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:c399023146e40ae373753a58d1be0a98bf6397fadc737b97ad612886b53df318"}, - {file = "debugpy-1.8.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09cc7b162586ea2171eea055985da2702b0723f6f907a423c9b2da5996ad67ba"}, - {file = "debugpy-1.8.8-cp311-cp311-win32.whl", hash = "sha256:eea8821d998ebeb02f0625dd0d76839ddde8cbf8152ebbe289dd7acf2cdc6b98"}, - {file = "debugpy-1.8.8-cp311-cp311-win_amd64.whl", hash = "sha256:d4483836da2a533f4b1454dffc9f668096ac0433de855f0c22cdce8c9f7e10c4"}, - {file = "debugpy-1.8.8-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:0cc94186340be87b9ac5a707184ec8f36547fb66636d1029ff4f1cc020e53996"}, - {file = "debugpy-1.8.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64674e95916e53c2e9540a056e5f489e0ad4872645399d778f7c598eacb7b7f9"}, - {file = "debugpy-1.8.8-cp312-cp312-win32.whl", hash = "sha256:5c6e885dbf12015aed73770f29dec7023cb310d0dc2ba8bfbeb5c8e43f80edc9"}, - {file = "debugpy-1.8.8-cp312-cp312-win_amd64.whl", hash = "sha256:19ffbd84e757a6ca0113574d1bf5a2298b3947320a3e9d7d8dc3377f02d9f864"}, - {file = "debugpy-1.8.8-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:705cd123a773d184860ed8dae99becd879dfec361098edbefb5fc0d3683eb804"}, - {file = "debugpy-1.8.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890fd16803f50aa9cb1a9b9b25b5ec321656dd6b78157c74283de241993d086f"}, - {file = "debugpy-1.8.8-cp313-cp313-win32.whl", hash = "sha256:90244598214bbe704aa47556ec591d2f9869ff9e042e301a2859c57106649add"}, - {file = "debugpy-1.8.8-cp313-cp313-win_amd64.whl", hash = "sha256:4b93e4832fd4a759a0c465c967214ed0c8a6e8914bced63a28ddb0dd8c5f078b"}, - {file = "debugpy-1.8.8-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:143ef07940aeb8e7316de48f5ed9447644da5203726fca378f3a6952a50a9eae"}, - {file = "debugpy-1.8.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f95651bdcbfd3b27a408869a53fbefcc2bcae13b694daee5f1365b1b83a00113"}, - {file = "debugpy-1.8.8-cp38-cp38-win32.whl", hash = "sha256:26b461123a030e82602a750fb24d7801776aa81cd78404e54ab60e8b5fecdad5"}, - {file = "debugpy-1.8.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3cbf1833e644a3100eadb6120f25be8a532035e8245584c4f7532937edc652a"}, - {file = "debugpy-1.8.8-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:53709d4ec586b525724819dc6af1a7703502f7e06f34ded7157f7b1f963bb854"}, - {file = "debugpy-1.8.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a9c013077a3a0000e83d97cf9cc9328d2b0bbb31f56b0e99ea3662d29d7a6a2"}, - {file = "debugpy-1.8.8-cp39-cp39-win32.whl", hash = "sha256:ffe94dd5e9a6739a75f0b85316dc185560db3e97afa6b215628d1b6a17561cb2"}, - {file = "debugpy-1.8.8-cp39-cp39-win_amd64.whl", hash = "sha256:5c0e5a38c7f9b481bf31277d2f74d2109292179081f11108e668195ef926c0f9"}, - {file = "debugpy-1.8.8-py2.py3-none-any.whl", hash = "sha256:ec684553aba5b4066d4de510859922419febc710df7bba04fe9e7ef3de15d34f"}, - {file = "debugpy-1.8.8.zip", hash = "sha256:e6355385db85cbd666be703a96ab7351bc9e6c61d694893206f8001e22aee091"}, -] - -[[package]] -name = "decorator" -version = "5.1.1" -description = "Decorators for Humans" -optional = false -python-versions = ">=3.5" -files = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -description = "XML bomb protection for Python stdlib modules" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, - {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, -] - -[[package]] -name = "dnspython" -version = "2.7.0" -description = "DNS toolkit" -optional = false -python-versions = ">=3.9" -files = [ - {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, - {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, -] - -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=43)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=1.0.0)"] -idna = ["idna (>=3.7)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - -[[package]] -name = "email-validator" -version = "2.2.0" -description = "A robust email address syntax and deliverability validation library." -optional = false -python-versions = ">=3.8" -files = [ - {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, - {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, -] - -[package.dependencies] -dnspython = ">=2.0.0" -idna = ">=2.0.0" - -[[package]] -name = "executing" -version = "2.1.0" -description = "Get the currently executing AST node of a frame, and other information" -optional = false -python-versions = ">=3.8" -files = [ - {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, - {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, -] - -[package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] - -[[package]] -name = "fastapi" -version = "0.115.5" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fastapi-0.115.5-py3-none-any.whl", hash = "sha256:596b95adbe1474da47049e802f9a65ab2ffa9c2b07e7efee70eb8a66c9f2f796"}, - {file = "fastapi-0.115.5.tar.gz", hash = "sha256:0e7a4d0dc0d01c68df21887cce0945e72d3c48b9f4f79dfe7a7d53aa08fbb289"}, -] - -[package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.42.0" -typing-extensions = ">=4.8.0" - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] - -[[package]] -name = "fastjsonschema" -version = "2.20.0" -description = "Fastest Python implementation of JSON schema" -optional = false -python-versions = "*" -files = [ - {file = "fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a"}, - {file = "fastjsonschema-2.20.0.tar.gz", hash = "sha256:3d48fc5300ee96f5d116f10fe6f28d938e6008f59a6a025c2649475b87f76a23"}, -] - -[package.extras] -devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] - -[[package]] -name = "fqdn" -version = "1.5.1" -description = "Validates fully-qualified domain names against RFC 1123, so that they are acceptable to modern bowsers" -optional = false -python-versions = ">=2.7, !=3.0, !=3.1, !=3.2, !=3.3, !=3.4, <4" -files = [ - {file = "fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014"}, - {file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"}, -] - -[[package]] -name = "graphviz" -version = "0.20.3" -description = "Simple Python interface for Graphviz" -optional = false -python-versions = ">=3.8" -files = [ - {file = "graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5"}, - {file = "graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d"}, -] - -[package.extras] -dev = ["flake8", "pep8-naming", "tox (>=3)", "twine", "wheel"] -docs = ["sphinx (>=5,<7)", "sphinx-autodoc-typehints", "sphinx-rtd-theme"] -test = ["coverage", "pytest (>=7,<8.1)", "pytest-cov", "pytest-mock (>=3)"] - -[[package]] -name = "greenlet" -version = "3.1.1" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.7" -files = [ - {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, - {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, - {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, - {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, - {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, - {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, - {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, - {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, - {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, - {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, - {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, - {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, - {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, - {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, - {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, - {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, - {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, - {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.27.2" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "ipykernel" -version = "6.29.5" -description = "IPython Kernel for Jupyter" -optional = false -python-versions = ">=3.8" -files = [ - {file = "ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5"}, - {file = "ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215"}, -] - -[package.dependencies] -appnope = {version = "*", markers = "platform_system == \"Darwin\""} -comm = ">=0.1.1" -debugpy = ">=1.6.5" -ipython = ">=7.23.1" -jupyter-client = ">=6.1.12" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" -matplotlib-inline = ">=0.1" -nest-asyncio = "*" -packaging = "*" -psutil = "*" -pyzmq = ">=24" -tornado = ">=6.1" -traitlets = ">=5.4.0" - -[package.extras] -cov = ["coverage[toml]", "curio", "matplotlib", "pytest-cov", "trio"] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] -pyqt5 = ["pyqt5"] -pyside6 = ["pyside6"] -test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "ipython" -version = "8.29.0" -description = "IPython: Productive Interactive Computing" -optional = false -python-versions = ">=3.10" -files = [ - {file = "ipython-8.29.0-py3-none-any.whl", hash = "sha256:0188a1bd83267192123ccea7f4a8ed0a78910535dbaa3f37671dca76ebd429c8"}, - {file = "ipython-8.29.0.tar.gz", hash = "sha256:40b60e15b22591450eef73e40a027cf77bd652e757523eebc5bd7c7c498290eb"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -jedi = ">=0.16" -matplotlib-inline = "*" -pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} -prompt-toolkit = ">=3.0.41,<3.1.0" -pygments = ">=2.4.0" -stack-data = "*" -traitlets = ">=5.13.0" - -[package.extras] -all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] -black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx-registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing-extensions"] -kernel = ["ipykernel"] -matplotlib = ["matplotlib"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["ipywidgets", "notebook"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] -test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] - -[[package]] -name = "ipywidgets" -version = "8.1.5" -description = "Jupyter interactive widgets" -optional = false -python-versions = ">=3.7" -files = [ - {file = "ipywidgets-8.1.5-py3-none-any.whl", hash = "sha256:3290f526f87ae6e77655555baba4f36681c555b8bdbbff430b70e52c34c86245"}, - {file = "ipywidgets-8.1.5.tar.gz", hash = "sha256:870e43b1a35656a80c18c9503bbf2d16802db1cb487eec6fab27d683381dde17"}, -] - -[package.dependencies] -comm = ">=0.1.3" -ipython = ">=6.1.0" -jupyterlab-widgets = ">=3.0.12,<3.1.0" -traitlets = ">=4.3.1" -widgetsnbextension = ">=4.0.12,<4.1.0" - -[package.extras] -test = ["ipykernel", "jsonschema", "pytest (>=3.6.0)", "pytest-cov", "pytz"] - -[[package]] -name = "isoduration" -version = "20.11.0" -description = "Operations with ISO 8601 durations" -optional = false -python-versions = ">=3.7" -files = [ - {file = "isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042"}, - {file = "isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9"}, -] - -[package.dependencies] -arrow = ">=0.15.0" - -[[package]] -name = "jedi" -version = "0.19.2" -description = "An autocompletion tool for Python that can be used for text editors." -optional = false -python-versions = ">=3.6" -files = [ - {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, - {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, -] - -[package.dependencies] -parso = ">=0.8.4,<0.9.0" - -[package.extras] -docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] -qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] - -[[package]] -name = "jinja2" -version = "3.1.4" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "json5" -version = "0.9.28" -description = "A Python implementation of the JSON5 data format." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "json5-0.9.28-py3-none-any.whl", hash = "sha256:29c56f1accdd8bc2e037321237662034a7e07921e2b7223281a5ce2c46f0c4df"}, - {file = "json5-0.9.28.tar.gz", hash = "sha256:1f82f36e615bc5b42f1bbd49dbc94b12563c56408c6ffa06414ea310890e9a6e"}, -] - -[package.extras] -dev = ["build (==1.2.2.post1)", "coverage (==7.5.3)", "mypy (==1.13.0)", "pip (==24.3.1)", "pylint (==3.2.3)", "ruff (==0.7.3)", "twine (==5.1.1)", "uv (==0.5.1)"] - -[[package]] -name = "jsonpointer" -version = "3.0.0" -description = "Identify specific nodes in a JSON document (RFC 6901)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, - {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, -] - -[[package]] -name = "jsonschema" -version = "4.23.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} -jsonschema-specifications = ">=2023.03.6" -referencing = ">=0.28.4" -rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} -rpds-py = ">=0.7.1" -uri-template = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -webcolors = {version = ">=24.6.0", optional = true, markers = "extra == \"format-nongpl\""} - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2024.10.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.9" -files = [ - {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, - {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "jupyter" -version = "1.1.1" -description = "Jupyter metapackage. Install all the Jupyter components in one go." -optional = false -python-versions = "*" -files = [ - {file = "jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83"}, - {file = "jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a"}, -] - -[package.dependencies] -ipykernel = "*" -ipywidgets = "*" -jupyter-console = "*" -jupyterlab = "*" -nbconvert = "*" -notebook = "*" - -[[package]] -name = "jupyter-client" -version = "8.6.3" -description = "Jupyter protocol implementation and client libraries" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f"}, - {file = "jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419"}, -] - -[package.dependencies] -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" -python-dateutil = ">=2.8.2" -pyzmq = ">=23.0" -tornado = ">=6.2" -traitlets = ">=5.3" - -[package.extras] -docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] - -[[package]] -name = "jupyter-console" -version = "6.6.3" -description = "Jupyter terminal console" -optional = false -python-versions = ">=3.7" -files = [ - {file = "jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485"}, - {file = "jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539"}, -] - -[package.dependencies] -ipykernel = ">=6.14" -ipython = "*" -jupyter-client = ">=7.0.0" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" -prompt-toolkit = ">=3.0.30" -pygments = "*" -pyzmq = ">=17" -traitlets = ">=5.4" - -[package.extras] -test = ["flaky", "pexpect", "pytest"] - -[[package]] -name = "jupyter-core" -version = "5.7.2" -description = "Jupyter core package. A base package on which Jupyter projects rely." -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409"}, - {file = "jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9"}, -] - -[package.dependencies] -platformdirs = ">=2.5" -pywin32 = {version = ">=300", markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\""} -traitlets = ">=5.3" - -[package.extras] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "traitlets"] -test = ["ipykernel", "pre-commit", "pytest (<8)", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "jupyter-events" -version = "0.10.0" -description = "Jupyter Event System library" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyter_events-0.10.0-py3-none-any.whl", hash = "sha256:4b72130875e59d57716d327ea70d3ebc3af1944d3717e5a498b8a06c6c159960"}, - {file = "jupyter_events-0.10.0.tar.gz", hash = "sha256:670b8229d3cc882ec782144ed22e0d29e1c2d639263f92ca8383e66682845e22"}, -] - -[package.dependencies] -jsonschema = {version = ">=4.18.0", extras = ["format-nongpl"]} -python-json-logger = ">=2.0.4" -pyyaml = ">=5.3" -referencing = "*" -rfc3339-validator = "*" -rfc3986-validator = ">=0.1.1" -traitlets = ">=5.3" - -[package.extras] -cli = ["click", "rich"] -docs = ["jupyterlite-sphinx", "myst-parser", "pydata-sphinx-theme", "sphinxcontrib-spelling"] -test = ["click", "pre-commit", "pytest (>=7.0)", "pytest-asyncio (>=0.19.0)", "pytest-console-scripts", "rich"] - -[[package]] -name = "jupyter-lsp" -version = "2.2.5" -description = "Multi-Language Server WebSocket proxy for Jupyter Notebook/Lab server" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyter-lsp-2.2.5.tar.gz", hash = "sha256:793147a05ad446f809fd53ef1cd19a9f5256fd0a2d6b7ce943a982cb4f545001"}, - {file = "jupyter_lsp-2.2.5-py3-none-any.whl", hash = "sha256:45fbddbd505f3fbfb0b6cb2f1bc5e15e83ab7c79cd6e89416b248cb3c00c11da"}, -] - -[package.dependencies] -jupyter-server = ">=1.1.2" - -[[package]] -name = "jupyter-server" -version = "2.14.2" -description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyter_server-2.14.2-py3-none-any.whl", hash = "sha256:47ff506127c2f7851a17bf4713434208fc490955d0e8632e95014a9a9afbeefd"}, - {file = "jupyter_server-2.14.2.tar.gz", hash = "sha256:66095021aa9638ced276c248b1d81862e4c50f292d575920bbe960de1c56b12b"}, -] - -[package.dependencies] -anyio = ">=3.1.0" -argon2-cffi = ">=21.1" -jinja2 = ">=3.0.3" -jupyter-client = ">=7.4.4" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" -jupyter-events = ">=0.9.0" -jupyter-server-terminals = ">=0.4.4" -nbconvert = ">=6.4.4" -nbformat = ">=5.3.0" -overrides = ">=5.0" -packaging = ">=22.0" -prometheus-client = ">=0.9" -pywinpty = {version = ">=2.0.1", markers = "os_name == \"nt\""} -pyzmq = ">=24" -send2trash = ">=1.8.2" -terminado = ">=0.8.3" -tornado = ">=6.2.0" -traitlets = ">=5.6.0" -websocket-client = ">=1.7" - -[package.extras] -docs = ["ipykernel", "jinja2", "jupyter-client", "myst-parser", "nbformat", "prometheus-client", "pydata-sphinx-theme", "send2trash", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-openapi (>=0.8.0)", "sphinxcontrib-spelling", "sphinxemoji", "tornado", "typing-extensions"] -test = ["flaky", "ipykernel", "pre-commit", "pytest (>=7.0,<9)", "pytest-console-scripts", "pytest-jupyter[server] (>=0.7)", "pytest-timeout", "requests"] - -[[package]] -name = "jupyter-server-terminals" -version = "0.5.3" -description = "A Jupyter Server Extension Providing Terminals." -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa"}, - {file = "jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269"}, -] - -[package.dependencies] -pywinpty = {version = ">=2.0.3", markers = "os_name == \"nt\""} -terminado = ">=0.8.3" - -[package.extras] -docs = ["jinja2", "jupyter-server", "mistune (<4.0)", "myst-parser", "nbformat", "packaging", "pydata-sphinx-theme", "sphinxcontrib-github-alt", "sphinxcontrib-openapi", "sphinxcontrib-spelling", "sphinxemoji", "tornado"] -test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>=0.5.3)", "pytest-timeout"] - -[[package]] -name = "jupyterlab" -version = "4.2.6" -description = "JupyterLab computational environment" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyterlab-4.2.6-py3-none-any.whl", hash = "sha256:78dd42cae5b460f377624b03966a8730e3b0692102ddf5933a2a3730c1bc0a20"}, - {file = "jupyterlab-4.2.6.tar.gz", hash = "sha256:625f3ac19da91f9706baf66df25723b2f1307c1159fc7293035b066786d62a4a"}, -] - -[package.dependencies] -async-lru = ">=1.0.0" -httpx = ">=0.25.0" -ipykernel = ">=6.5.0" -jinja2 = ">=3.0.3" -jupyter-core = "*" -jupyter-lsp = ">=2.0.0" -jupyter-server = ">=2.4.0,<3" -jupyterlab-server = ">=2.27.1,<3" -notebook-shim = ">=0.2" -packaging = "*" -setuptools = ">=40.1.0" -tornado = ">=6.2.0" -traitlets = "*" - -[package.extras] -dev = ["build", "bump2version", "coverage", "hatch", "pre-commit", "pytest-cov", "ruff (==0.3.5)"] -docs = ["jsx-lexer", "myst-parser", "pydata-sphinx-theme (>=0.13.0)", "pytest", "pytest-check-links", "pytest-jupyter", "sphinx (>=1.8,<7.3.0)", "sphinx-copybutton"] -docs-screenshots = ["altair (==5.3.0)", "ipython (==8.16.1)", "ipywidgets (==8.1.2)", "jupyterlab-geojson (==3.4.0)", "jupyterlab-language-pack-zh-cn (==4.1.post2)", "matplotlib (==3.8.3)", "nbconvert (>=7.0.0)", "pandas (==2.2.1)", "scipy (==1.12.0)", "vega-datasets (==0.9.0)"] -test = ["coverage", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter (>=0.5.3)", "pytest-timeout", "pytest-tornasync", "requests", "requests-cache", "virtualenv"] -upgrade-extension = ["copier (>=9,<10)", "jinja2-time (<0.3)", "pydantic (<3.0)", "pyyaml-include (<3.0)", "tomli-w (<2.0)"] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -description = "Pygments theme using JupyterLab CSS variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780"}, - {file = "jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d"}, -] - -[[package]] -name = "jupyterlab-server" -version = "2.27.3" -description = "A set of server components for JupyterLab and JupyterLab like applications." -optional = false -python-versions = ">=3.8" -files = [ - {file = "jupyterlab_server-2.27.3-py3-none-any.whl", hash = "sha256:e697488f66c3db49df675158a77b3b017520d772c6e1548c7d9bcc5df7944ee4"}, - {file = "jupyterlab_server-2.27.3.tar.gz", hash = "sha256:eb36caca59e74471988f0ae25c77945610b887f777255aa21f8065def9e51ed4"}, -] - -[package.dependencies] -babel = ">=2.10" -jinja2 = ">=3.0.3" -json5 = ">=0.9.0" -jsonschema = ">=4.18.0" -jupyter-server = ">=1.21,<3" -packaging = ">=21.3" -requests = ">=2.31" - -[package.extras] -docs = ["autodoc-traits", "jinja2 (<3.2.0)", "mistune (<4)", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinxcontrib-openapi (>0.8)"] -openapi = ["openapi-core (>=0.18.0,<0.19.0)", "ruamel-yaml"] -test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-validator (>=0.6.0,<0.8.0)", "pytest (>=7.0,<8)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter[server] (>=0.6.2)", "pytest-timeout", "requests-mock", "ruamel-yaml", "sphinxcontrib-spelling", "strict-rfc3339", "werkzeug"] - -[[package]] -name = "jupyterlab-widgets" -version = "3.0.13" -description = "Jupyter interactive widgets for JupyterLab" -optional = false -python-versions = ">=3.7" -files = [ - {file = "jupyterlab_widgets-3.0.13-py3-none-any.whl", hash = "sha256:e3cda2c233ce144192f1e29914ad522b2f4c40e77214b0cc97377ca3d323db54"}, - {file = "jupyterlab_widgets-3.0.13.tar.gz", hash = "sha256:a2966d385328c1942b683a8cd96b89b8dd82c8b8f81dda902bb2bc06d46f5bed"}, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - -[[package]] -name = "matplotlib-inline" -version = "0.1.7" -description = "Inline Matplotlib backend for Jupyter" -optional = false -python-versions = ">=3.8" -files = [ - {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, - {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, -] - -[package.dependencies] -traitlets = "*" - -[[package]] -name = "mistune" -version = "3.0.2" -description = "A sane and fast Markdown parser with useful plugins and renderers" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205"}, - {file = "mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8"}, -] - -[[package]] -name = "mypy" -version = "1.13.0" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, - {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, - {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, - {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, - {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, - {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, - {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, - {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, - {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, - {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, - {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, - {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, - {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, - {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, - {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, - {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, - {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, - {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, - {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, - {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, - {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, - {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -typing-extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "nbclient" -version = "0.10.0" -description = "A client library for executing notebooks. Formerly nbconvert's ExecutePreprocessor." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "nbclient-0.10.0-py3-none-any.whl", hash = "sha256:f13e3529332a1f1f81d82a53210322476a168bb7090a0289c795fe9cc11c9d3f"}, - {file = "nbclient-0.10.0.tar.gz", hash = "sha256:4b3f1b7dba531e498449c4db4f53da339c91d449dc11e9af3a43b4eb5c5abb09"}, -] - -[package.dependencies] -jupyter-client = ">=6.1.12" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" -nbformat = ">=5.1" -traitlets = ">=5.4" - -[package.extras] -dev = ["pre-commit"] -docs = ["autodoc-traits", "mock", "moto", "myst-parser", "nbclient[test]", "sphinx (>=1.7)", "sphinx-book-theme", "sphinxcontrib-spelling"] -test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=7.0.0)", "pytest (>=7.0,<8)", "pytest-asyncio", "pytest-cov (>=4.0)", "testpath", "xmltodict"] - -[[package]] -name = "nbconvert" -version = "7.16.4" -description = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)." -optional = false -python-versions = ">=3.8" -files = [ - {file = "nbconvert-7.16.4-py3-none-any.whl", hash = "sha256:05873c620fe520b6322bf8a5ad562692343fe3452abda5765c7a34b7d1aa3eb3"}, - {file = "nbconvert-7.16.4.tar.gz", hash = "sha256:86ca91ba266b0a448dc96fa6c5b9d98affabde2867b363258703536807f9f7f4"}, -] - -[package.dependencies] -beautifulsoup4 = "*" -bleach = "!=5.0.0" -defusedxml = "*" -jinja2 = ">=3.0" -jupyter-core = ">=4.7" -jupyterlab-pygments = "*" -markupsafe = ">=2.0" -mistune = ">=2.0.3,<4" -nbclient = ">=0.5.0" -nbformat = ">=5.7" -packaging = "*" -pandocfilters = ">=1.4.1" -pygments = ">=2.4.1" -tinycss2 = "*" -traitlets = ">=5.1" - -[package.extras] -all = ["flaky", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (==5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"] -docs = ["ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (==5.0.2)", "sphinxcontrib-spelling"] -qtpdf = ["pyqtwebengine (>=5.15)"] -qtpng = ["pyqtwebengine (>=5.15)"] -serve = ["tornado (>=6.1)"] -test = ["flaky", "ipykernel", "ipywidgets (>=7.5)", "pytest (>=7)"] -webpdf = ["playwright"] - -[[package]] -name = "nbformat" -version = "5.10.4" -description = "The Jupyter Notebook format" -optional = false -python-versions = ">=3.8" -files = [ - {file = "nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b"}, - {file = "nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a"}, -] - -[package.dependencies] -fastjsonschema = ">=2.15" -jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" -traitlets = ">=5.1" - -[package.extras] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["pep440", "pre-commit", "pytest", "testpath"] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -description = "Patch asyncio to allow nested event loops" -optional = false -python-versions = ">=3.5" -files = [ - {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, - {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, -] - -[[package]] -name = "notebook" -version = "7.2.2" -description = "Jupyter Notebook - A web-based notebook environment for interactive computing" -optional = false -python-versions = ">=3.8" -files = [ - {file = "notebook-7.2.2-py3-none-any.whl", hash = "sha256:c89264081f671bc02eec0ed470a627ed791b9156cad9285226b31611d3e9fe1c"}, - {file = "notebook-7.2.2.tar.gz", hash = "sha256:2ef07d4220421623ad3fe88118d687bc0450055570cdd160814a59cf3a1c516e"}, -] - -[package.dependencies] -jupyter-server = ">=2.4.0,<3" -jupyterlab = ">=4.2.0,<4.3" -jupyterlab-server = ">=2.27.1,<3" -notebook-shim = ">=0.2,<0.3" -tornado = ">=6.2.0" - -[package.extras] -dev = ["hatch", "pre-commit"] -docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.27.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] - -[[package]] -name = "notebook-shim" -version = "0.2.4" -description = "A shim layer for notebook traits and config" -optional = false -python-versions = ">=3.7" -files = [ - {file = "notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef"}, - {file = "notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb"}, -] - -[package.dependencies] -jupyter-server = ">=1.8,<3" - -[package.extras] -test = ["pytest", "pytest-console-scripts", "pytest-jupyter", "pytest-tornasync"] - -[[package]] -name = "overrides" -version = "7.7.0" -description = "A decorator to automatically detect mismatch when overriding a method." -optional = false -python-versions = ">=3.6" -files = [ - {file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"}, - {file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"}, -] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "pandocfilters" -version = "1.5.1" -description = "Utilities for writing pandoc filters in python" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc"}, - {file = "pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e"}, -] - -[[package]] -name = "parso" -version = "0.8.4" -description = "A Python Parser" -optional = false -python-versions = ">=3.6" -files = [ - {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, - {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, -] - -[package.extras] -qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] -testing = ["docopt", "pytest"] - -[[package]] -name = "pexpect" -version = "4.9.0" -description = "Pexpect allows easy control of interactive console applications." -optional = false -python-versions = "*" -files = [ - {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, - {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, -] - -[package.dependencies] -ptyprocess = ">=0.5" - -[[package]] -name = "pillow" -version = "11.0.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, - {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, - {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, - {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, - {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, - {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, - {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, - {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, - {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, - {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, - {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, - {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, - {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, - {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, - {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, - {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, - {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, - {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, - {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, - {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, - {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, - {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] -xmp = ["defusedxml"] - -[[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "prometheus-client" -version = "0.21.0" -description = "Python client for the Prometheus monitoring system." -optional = false -python-versions = ">=3.8" -files = [ - {file = "prometheus_client-0.21.0-py3-none-any.whl", hash = "sha256:4fa6b4dd0ac16d58bb587c04b1caae65b8c5043e85f778f42f5f632f6af2e166"}, - {file = "prometheus_client-0.21.0.tar.gz", hash = "sha256:96c83c606b71ff2b0a433c98889d275f51ffec6c5e267de37c7a2b5c9aa9233e"}, -] - -[package.extras] -twisted = ["twisted"] - -[[package]] -name = "prompt-toolkit" -version = "3.0.48" -description = "Library for building powerful interactive command lines in Python" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, - {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, -] - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "psutil" -version = "6.1.0" -description = "Cross-platform lib for process and system monitoring in Python." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -files = [ - {file = "psutil-6.1.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ff34df86226c0227c52f38b919213157588a678d049688eded74c76c8ba4a5d0"}, - {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c0e0c00aa18ca2d3b2b991643b799a15fc8f0563d2ebb6040f64ce8dc027b942"}, - {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:000d1d1ebd634b4efb383f4034437384e44a6d455260aaee2eca1e9c1b55f047"}, - {file = "psutil-6.1.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5cd2bcdc75b452ba2e10f0e8ecc0b57b827dd5d7aaffbc6821b2a9a242823a76"}, - {file = "psutil-6.1.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:045f00a43c737f960d273a83973b2511430d61f283a44c96bf13a6e829ba8fdc"}, - {file = "psutil-6.1.0-cp27-none-win32.whl", hash = "sha256:9118f27452b70bb1d9ab3198c1f626c2499384935aaf55388211ad982611407e"}, - {file = "psutil-6.1.0-cp27-none-win_amd64.whl", hash = "sha256:a8506f6119cff7015678e2bce904a4da21025cc70ad283a53b099e7620061d85"}, - {file = "psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688"}, - {file = "psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e"}, - {file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38"}, - {file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b"}, - {file = "psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a"}, - {file = "psutil-6.1.0-cp36-cp36m-win32.whl", hash = "sha256:6d3fbbc8d23fcdcb500d2c9f94e07b1342df8ed71b948a2649b5cb060a7c94ca"}, - {file = "psutil-6.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1209036fbd0421afde505a4879dee3b2fd7b1e14fee81c0069807adcbbcca747"}, - {file = "psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e"}, - {file = "psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be"}, - {file = "psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a"}, -] - -[package.extras] -dev = ["black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "wheel"] -test = ["pytest", "pytest-xdist", "setuptools"] - -[[package]] -name = "psycopg2" -version = "2.9.10" -description = "psycopg2 - Python-PostgreSQL Database Adapter" -optional = false -python-versions = ">=3.8" -files = [ - {file = "psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716"}, - {file = "psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a"}, - {file = "psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2"}, - {file = "psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4"}, - {file = "psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067"}, - {file = "psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e"}, - {file = "psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b"}, - {file = "psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442"}, - {file = "psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11"}, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -description = "Run a subprocess in a pseudo terminal" -optional = false -python-versions = "*" -files = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -description = "Safely evaluate AST nodes without side effects" -optional = false -python-versions = "*" -files = [ - {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, - {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, -] - -[package.extras] -tests = ["pytest"] - -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pydantic" -version = "2.9.2" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} -pydantic-core = "2.23.4" -typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, -] - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] - -[[package]] -name = "pydantic-core" -version = "2.23.4" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, - {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, - {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, - {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, - {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, - {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, - {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, - {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, - {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, - {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, - {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pydot" -version = "3.0.2" -description = "Python interface to Graphviz's Dot" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydot-3.0.2-py3-none-any.whl", hash = "sha256:99cedaa55d04abb0b2bc56d9981a6da781053dd5ac75c428e8dd53db53f90b14"}, - {file = "pydot-3.0.2.tar.gz", hash = "sha256:9180da540b51b3aa09fbf81140b3edfbe2315d778e8589a7d0a4a69c41332bae"}, -] - -[package.dependencies] -pyparsing = ">=3.0.9" - -[package.extras] -dev = ["chardet", "parameterized", "ruff"] -release = ["zest.releaser[recommended]"] -tests = ["chardet", "parameterized", "pytest", "pytest-cov", "pytest-xdist[psutil]", "ruff", "tox"] - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.10.1" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, -] - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - -[[package]] -name = "pyparsing" -version = "3.2.0" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, - {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "8.3.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "python-json-logger" -version = "2.0.7" -description = "A python library adding a json log formatter" -optional = false -python-versions = ">=3.6" -files = [ - {file = "python-json-logger-2.0.7.tar.gz", hash = "sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c"}, - {file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"}, -] - -[[package]] -name = "python-multipart" -version = "0.0.17" -description = "A streaming multipart parser for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"}, - {file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"}, -] - -[[package]] -name = "pywin32" -version = "308" -description = "Python for Window Extensions" -optional = false -python-versions = "*" -files = [ - {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, - {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, - {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"}, - {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"}, - {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"}, - {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"}, - {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"}, - {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"}, - {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"}, - {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"}, - {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"}, - {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"}, - {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"}, - {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"}, - {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"}, - {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"}, - {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, - {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, -] - -[[package]] -name = "pywinpty" -version = "2.0.14" -description = "Pseudo terminal support for Windows from Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pywinpty-2.0.14-cp310-none-win_amd64.whl", hash = "sha256:0b149c2918c7974f575ba79f5a4aad58bd859a52fa9eb1296cc22aa412aa411f"}, - {file = "pywinpty-2.0.14-cp311-none-win_amd64.whl", hash = "sha256:cf2a43ac7065b3e0dc8510f8c1f13a75fb8fde805efa3b8cff7599a1ef497bc7"}, - {file = "pywinpty-2.0.14-cp312-none-win_amd64.whl", hash = "sha256:55dad362ef3e9408ade68fd173e4f9032b3ce08f68cfe7eacb2c263ea1179737"}, - {file = "pywinpty-2.0.14-cp313-none-win_amd64.whl", hash = "sha256:074fb988a56ec79ca90ed03a896d40707131897cefb8f76f926e3834227f2819"}, - {file = "pywinpty-2.0.14-cp39-none-win_amd64.whl", hash = "sha256:5725fd56f73c0531ec218663bd8c8ff5acc43c78962fab28564871b5fce053fd"}, - {file = "pywinpty-2.0.14.tar.gz", hash = "sha256:18bd9529e4a5daf2d9719aa17788ba6013e594ae94c5a0c27e83df3278b0660e"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "pyzmq" -version = "26.2.0" -description = "Python bindings for 0MQ" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pyzmq-26.2.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:ddf33d97d2f52d89f6e6e7ae66ee35a4d9ca6f36eda89c24591b0c40205a3629"}, - {file = "pyzmq-26.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dacd995031a01d16eec825bf30802fceb2c3791ef24bcce48fa98ce40918c27b"}, - {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89289a5ee32ef6c439086184529ae060c741334b8970a6855ec0b6ad3ff28764"}, - {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5506f06d7dc6ecf1efacb4a013b1f05071bb24b76350832c96449f4a2d95091c"}, - {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ea039387c10202ce304af74def5021e9adc6297067f3441d348d2b633e8166a"}, - {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a2224fa4a4c2ee872886ed00a571f5e967c85e078e8e8c2530a2fb01b3309b88"}, - {file = "pyzmq-26.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:28ad5233e9c3b52d76196c696e362508959741e1a005fb8fa03b51aea156088f"}, - {file = "pyzmq-26.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1c17211bc037c7d88e85ed8b7d8f7e52db6dc8eca5590d162717c654550f7282"}, - {file = "pyzmq-26.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b8f86dd868d41bea9a5f873ee13bf5551c94cf6bc51baebc6f85075971fe6eea"}, - {file = "pyzmq-26.2.0-cp310-cp310-win32.whl", hash = "sha256:46a446c212e58456b23af260f3d9fb785054f3e3653dbf7279d8f2b5546b21c2"}, - {file = "pyzmq-26.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:49d34ab71db5a9c292a7644ce74190b1dd5a3475612eefb1f8be1d6961441971"}, - {file = "pyzmq-26.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:bfa832bfa540e5b5c27dcf5de5d82ebc431b82c453a43d141afb1e5d2de025fa"}, - {file = "pyzmq-26.2.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:8f7e66c7113c684c2b3f1c83cdd3376103ee0ce4c49ff80a648643e57fb22218"}, - {file = "pyzmq-26.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3a495b30fc91db2db25120df5847d9833af237546fd59170701acd816ccc01c4"}, - {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77eb0968da535cba0470a5165468b2cac7772cfb569977cff92e240f57e31bef"}, - {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ace4f71f1900a548f48407fc9be59c6ba9d9aaf658c2eea6cf2779e72f9f317"}, - {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92a78853d7280bffb93df0a4a6a2498cba10ee793cc8076ef797ef2f74d107cf"}, - {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:689c5d781014956a4a6de61d74ba97b23547e431e9e7d64f27d4922ba96e9d6e"}, - {file = "pyzmq-26.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0aca98bc423eb7d153214b2df397c6421ba6373d3397b26c057af3c904452e37"}, - {file = "pyzmq-26.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f3496d76b89d9429a656293744ceca4d2ac2a10ae59b84c1da9b5165f429ad3"}, - {file = "pyzmq-26.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5c2b3bfd4b9689919db068ac6c9911f3fcb231c39f7dd30e3138be94896d18e6"}, - {file = "pyzmq-26.2.0-cp311-cp311-win32.whl", hash = "sha256:eac5174677da084abf378739dbf4ad245661635f1600edd1221f150b165343f4"}, - {file = "pyzmq-26.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a509df7d0a83a4b178d0f937ef14286659225ef4e8812e05580776c70e155d5"}, - {file = "pyzmq-26.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0e6091b157d48cbe37bd67233318dbb53e1e6327d6fc3bb284afd585d141003"}, - {file = "pyzmq-26.2.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ded0fc7d90fe93ae0b18059930086c51e640cdd3baebdc783a695c77f123dcd9"}, - {file = "pyzmq-26.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:17bf5a931c7f6618023cdacc7081f3f266aecb68ca692adac015c383a134ca52"}, - {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55cf66647e49d4621a7e20c8d13511ef1fe1efbbccf670811864452487007e08"}, - {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4661c88db4a9e0f958c8abc2b97472e23061f0bc737f6f6179d7a27024e1faa5"}, - {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea7f69de383cb47522c9c208aec6dd17697db7875a4674c4af3f8cfdac0bdeae"}, - {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7f98f6dfa8b8ccaf39163ce872bddacca38f6a67289116c8937a02e30bbe9711"}, - {file = "pyzmq-26.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3e0210287329272539eea617830a6a28161fbbd8a3271bf4150ae3e58c5d0e6"}, - {file = "pyzmq-26.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b274e0762c33c7471f1a7471d1a2085b1a35eba5cdc48d2ae319f28b6fc4de3"}, - {file = "pyzmq-26.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29c6a4635eef69d68a00321e12a7d2559fe2dfccfa8efae3ffb8e91cd0b36a8b"}, - {file = "pyzmq-26.2.0-cp312-cp312-win32.whl", hash = "sha256:989d842dc06dc59feea09e58c74ca3e1678c812a4a8a2a419046d711031f69c7"}, - {file = "pyzmq-26.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a50625acdc7801bc6f74698c5c583a491c61d73c6b7ea4dee3901bb99adb27a"}, - {file = "pyzmq-26.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d29ab8592b6ad12ebbf92ac2ed2bedcfd1cec192d8e559e2e099f648570e19b"}, - {file = "pyzmq-26.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9dd8cd1aeb00775f527ec60022004d030ddc51d783d056e3e23e74e623e33726"}, - {file = "pyzmq-26.2.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:28c812d9757fe8acecc910c9ac9dafd2ce968c00f9e619db09e9f8f54c3a68a3"}, - {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d80b1dd99c1942f74ed608ddb38b181b87476c6a966a88a950c7dee118fdf50"}, - {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c997098cc65e3208eca09303630e84d42718620e83b733d0fd69543a9cab9cb"}, - {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad1bc8d1b7a18497dda9600b12dc193c577beb391beae5cd2349184db40f187"}, - {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bea2acdd8ea4275e1278350ced63da0b166421928276c7c8e3f9729d7402a57b"}, - {file = "pyzmq-26.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:23f4aad749d13698f3f7b64aad34f5fc02d6f20f05999eebc96b89b01262fb18"}, - {file = "pyzmq-26.2.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a4f96f0d88accc3dbe4a9025f785ba830f968e21e3e2c6321ccdfc9aef755115"}, - {file = "pyzmq-26.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ced65e5a985398827cc9276b93ef6dfabe0273c23de8c7931339d7e141c2818e"}, - {file = "pyzmq-26.2.0-cp313-cp313-win32.whl", hash = "sha256:31507f7b47cc1ead1f6e86927f8ebb196a0bab043f6345ce070f412a59bf87b5"}, - {file = "pyzmq-26.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:70fc7fcf0410d16ebdda9b26cbd8bf8d803d220a7f3522e060a69a9c87bf7bad"}, - {file = "pyzmq-26.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c3789bd5768ab5618ebf09cef6ec2b35fed88709b104351748a63045f0ff9797"}, - {file = "pyzmq-26.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:034da5fc55d9f8da09015d368f519478a52675e558c989bfcb5cf6d4e16a7d2a"}, - {file = "pyzmq-26.2.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c92d73464b886931308ccc45b2744e5968cbaade0b1d6aeb40d8ab537765f5bc"}, - {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:794a4562dcb374f7dbbfb3f51d28fb40123b5a2abadee7b4091f93054909add5"}, - {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aee22939bb6075e7afededabad1a56a905da0b3c4e3e0c45e75810ebe3a52672"}, - {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ae90ff9dad33a1cfe947d2c40cb9cb5e600d759ac4f0fd22616ce6540f72797"}, - {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:43a47408ac52647dfabbc66a25b05b6a61700b5165807e3fbd40063fcaf46386"}, - {file = "pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:25bf2374a2a8433633c65ccb9553350d5e17e60c8eb4de4d92cc6bd60f01d306"}, - {file = "pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:007137c9ac9ad5ea21e6ad97d3489af654381324d5d3ba614c323f60dab8fae6"}, - {file = "pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:470d4a4f6d48fb34e92d768b4e8a5cc3780db0d69107abf1cd7ff734b9766eb0"}, - {file = "pyzmq-26.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b55a4229ce5da9497dd0452b914556ae58e96a4381bb6f59f1305dfd7e53fc8"}, - {file = "pyzmq-26.2.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9cb3a6460cdea8fe8194a76de8895707e61ded10ad0be97188cc8463ffa7e3a8"}, - {file = "pyzmq-26.2.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ab5cad923cc95c87bffee098a27856c859bd5d0af31bd346035aa816b081fe1"}, - {file = "pyzmq-26.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ed69074a610fad1c2fda66180e7b2edd4d31c53f2d1872bc2d1211563904cd9"}, - {file = "pyzmq-26.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cccba051221b916a4f5e538997c45d7d136a5646442b1231b916d0164067ea27"}, - {file = "pyzmq-26.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:0eaa83fc4c1e271c24eaf8fb083cbccef8fde77ec8cd45f3c35a9a123e6da097"}, - {file = "pyzmq-26.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9edda2df81daa129b25a39b86cb57dfdfe16f7ec15b42b19bfac503360d27a93"}, - {file = "pyzmq-26.2.0-cp37-cp37m-win32.whl", hash = "sha256:ea0eb6af8a17fa272f7b98d7bebfab7836a0d62738e16ba380f440fceca2d951"}, - {file = "pyzmq-26.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4ff9dc6bc1664bb9eec25cd17506ef6672d506115095411e237d571e92a58231"}, - {file = "pyzmq-26.2.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:2eb7735ee73ca1b0d71e0e67c3739c689067f055c764f73aac4cc8ecf958ee3f"}, - {file = "pyzmq-26.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a534f43bc738181aa7cbbaf48e3eca62c76453a40a746ab95d4b27b1111a7d2"}, - {file = "pyzmq-26.2.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aedd5dd8692635813368e558a05266b995d3d020b23e49581ddd5bbe197a8ab6"}, - {file = "pyzmq-26.2.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8be4700cd8bb02cc454f630dcdf7cfa99de96788b80c51b60fe2fe1dac480289"}, - {file = "pyzmq-26.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fcc03fa4997c447dce58264e93b5aa2d57714fbe0f06c07b7785ae131512732"}, - {file = "pyzmq-26.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:402b190912935d3db15b03e8f7485812db350d271b284ded2b80d2e5704be780"}, - {file = "pyzmq-26.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8685fa9c25ff00f550c1fec650430c4b71e4e48e8d852f7ddcf2e48308038640"}, - {file = "pyzmq-26.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:76589c020680778f06b7e0b193f4b6dd66d470234a16e1df90329f5e14a171cd"}, - {file = "pyzmq-26.2.0-cp38-cp38-win32.whl", hash = "sha256:8423c1877d72c041f2c263b1ec6e34360448decfb323fa8b94e85883043ef988"}, - {file = "pyzmq-26.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:76589f2cd6b77b5bdea4fca5992dc1c23389d68b18ccc26a53680ba2dc80ff2f"}, - {file = "pyzmq-26.2.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:b1d464cb8d72bfc1a3adc53305a63a8e0cac6bc8c5a07e8ca190ab8d3faa43c2"}, - {file = "pyzmq-26.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4da04c48873a6abdd71811c5e163bd656ee1b957971db7f35140a2d573f6949c"}, - {file = "pyzmq-26.2.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d049df610ac811dcffdc147153b414147428567fbbc8be43bb8885f04db39d98"}, - {file = "pyzmq-26.2.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05590cdbc6b902101d0e65d6a4780af14dc22914cc6ab995d99b85af45362cc9"}, - {file = "pyzmq-26.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c811cfcd6a9bf680236c40c6f617187515269ab2912f3d7e8c0174898e2519db"}, - {file = "pyzmq-26.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6835dd60355593de10350394242b5757fbbd88b25287314316f266e24c61d073"}, - {file = "pyzmq-26.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc6bee759a6bddea5db78d7dcd609397449cb2d2d6587f48f3ca613b19410cfc"}, - {file = "pyzmq-26.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c530e1eecd036ecc83c3407f77bb86feb79916d4a33d11394b8234f3bd35b940"}, - {file = "pyzmq-26.2.0-cp39-cp39-win32.whl", hash = "sha256:367b4f689786fca726ef7a6c5ba606958b145b9340a5e4808132cc65759abd44"}, - {file = "pyzmq-26.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:e6fa2e3e683f34aea77de8112f6483803c96a44fd726d7358b9888ae5bb394ec"}, - {file = "pyzmq-26.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:7445be39143a8aa4faec43b076e06944b8f9d0701b669df4af200531b21e40bb"}, - {file = "pyzmq-26.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:706e794564bec25819d21a41c31d4df2d48e1cc4b061e8d345d7fb4dd3e94072"}, - {file = "pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b435f2753621cd36e7c1762156815e21c985c72b19135dac43a7f4f31d28dd1"}, - {file = "pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:160c7e0a5eb178011e72892f99f918c04a131f36056d10d9c1afb223fc952c2d"}, - {file = "pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4a71d5d6e7b28a47a394c0471b7e77a0661e2d651e7ae91e0cab0a587859ca"}, - {file = "pyzmq-26.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:90412f2db8c02a3864cbfc67db0e3dcdbda336acf1c469526d3e869394fe001c"}, - {file = "pyzmq-26.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ea4ad4e6a12e454de05f2949d4beddb52460f3de7c8b9d5c46fbb7d7222e02c"}, - {file = "pyzmq-26.2.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fc4f7a173a5609631bb0c42c23d12c49df3966f89f496a51d3eb0ec81f4519d6"}, - {file = "pyzmq-26.2.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:878206a45202247781472a2d99df12a176fef806ca175799e1c6ad263510d57c"}, - {file = "pyzmq-26.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17c412bad2eb9468e876f556eb4ee910e62d721d2c7a53c7fa31e643d35352e6"}, - {file = "pyzmq-26.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:0d987a3ae5a71c6226b203cfd298720e0086c7fe7c74f35fa8edddfbd6597eed"}, - {file = "pyzmq-26.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:39887ac397ff35b7b775db7201095fc6310a35fdbae85bac4523f7eb3b840e20"}, - {file = "pyzmq-26.2.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fdb5b3e311d4d4b0eb8b3e8b4d1b0a512713ad7e6a68791d0923d1aec433d919"}, - {file = "pyzmq-26.2.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:226af7dcb51fdb0109f0016449b357e182ea0ceb6b47dfb5999d569e5db161d5"}, - {file = "pyzmq-26.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bed0e799e6120b9c32756203fb9dfe8ca2fb8467fed830c34c877e25638c3fc"}, - {file = "pyzmq-26.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:29c7947c594e105cb9e6c466bace8532dc1ca02d498684128b339799f5248277"}, - {file = "pyzmq-26.2.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cdeabcff45d1c219636ee2e54d852262e5c2e085d6cb476d938aee8d921356b3"}, - {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35cffef589bcdc587d06f9149f8d5e9e8859920a071df5a2671de2213bef592a"}, - {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18c8dc3b7468d8b4bdf60ce9d7141897da103c7a4690157b32b60acb45e333e6"}, - {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7133d0a1677aec369d67dd78520d3fa96dd7f3dcec99d66c1762870e5ea1a50a"}, - {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6a96179a24b14fa6428cbfc08641c779a53f8fcec43644030328f44034c7f1f4"}, - {file = "pyzmq-26.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4f78c88905461a9203eac9faac157a2a0dbba84a0fd09fd29315db27be40af9f"}, - {file = "pyzmq-26.2.0.tar.gz", hash = "sha256:070672c258581c8e4f640b5159297580a9974b026043bd4ab0470be9ed324f1f"}, -] - -[package.dependencies] -cffi = {version = "*", markers = "implementation_name == \"pypy\""} - -[[package]] -name = "quarto" -version = "0.1.0" -description = "Python Interface to 'Quarto' Markdown Publishing System" -optional = false -python-versions = ">=3.6" -files = [ - {file = "quarto-0.1.0-py3-none-any.whl", hash = "sha256:8138fc9d1bee6269a5436837baedc699a262be1478f96444d6f99029f1a114f0"}, - {file = "quarto-0.1.0.tar.gz", hash = "sha256:d9a4978110204f5b9d3af39faa9e195083efcbac8f6a23789e29cb27c72ded15"}, -] - -[package.dependencies] -ipykernel = "*" -jupyter-core = "*" -nbclient = "*" -nbformat = "*" -pyyaml = "*" - -[[package]] -name = "referencing" -version = "0.35.1" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, - {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "resend" -version = "2.4.0" -description = "Resend Python SDK" -optional = false -python-versions = ">=3.7" -files = [ - {file = "resend-2.4.0-py2.py3-none-any.whl", hash = "sha256:92b674e4877bca9b65c38bb06b5b29a509f2fa878f147b58872afe380738e448"}, - {file = "resend-2.4.0.tar.gz", hash = "sha256:0f2b06c9afdc5c71f2d2f828dcd6680077b0ef3a3d7420ec37d150857b67a095"}, -] - -[package.dependencies] -requests = ">=2.31.0" -typing-extensions = "*" - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -description = "A pure python RFC3339 validator" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, - {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, -] - -[package.dependencies] -six = "*" - -[[package]] -name = "rfc3986-validator" -version = "0.1.1" -description = "Pure python rfc3986 validator" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9"}, - {file = "rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055"}, -] - -[[package]] -name = "rpds-py" -version = "0.21.0" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.9" -files = [ - {file = "rpds_py-0.21.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a017f813f24b9df929674d0332a374d40d7f0162b326562daae8066b502d0590"}, - {file = "rpds_py-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:20cc1ed0bcc86d8e1a7e968cce15be45178fd16e2ff656a243145e0b439bd250"}, - {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad116dda078d0bc4886cb7840e19811562acdc7a8e296ea6ec37e70326c1b41c"}, - {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:808f1ac7cf3b44f81c9475475ceb221f982ef548e44e024ad5f9e7060649540e"}, - {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de552f4a1916e520f2703ec474d2b4d3f86d41f353e7680b597512ffe7eac5d0"}, - {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:efec946f331349dfc4ae9d0e034c263ddde19414fe5128580f512619abed05f1"}, - {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b80b4690bbff51a034bfde9c9f6bf9357f0a8c61f548942b80f7b66356508bf5"}, - {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085ed25baac88953d4283e5b5bd094b155075bb40d07c29c4f073e10623f9f2e"}, - {file = "rpds_py-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:daa8efac2a1273eed2354397a51216ae1e198ecbce9036fba4e7610b308b6153"}, - {file = "rpds_py-0.21.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:95a5bad1ac8a5c77b4e658671642e4af3707f095d2b78a1fdd08af0dfb647624"}, - {file = "rpds_py-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3e53861b29a13d5b70116ea4230b5f0f3547b2c222c5daa090eb7c9c82d7f664"}, - {file = "rpds_py-0.21.0-cp310-none-win32.whl", hash = "sha256:ea3a6ac4d74820c98fcc9da4a57847ad2cc36475a8bd9683f32ab6d47a2bd682"}, - {file = "rpds_py-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:b8f107395f2f1d151181880b69a2869c69e87ec079c49c0016ab96860b6acbe5"}, - {file = "rpds_py-0.21.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5555db3e618a77034954b9dc547eae94166391a98eb867905ec8fcbce1308d95"}, - {file = "rpds_py-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97ef67d9bbc3e15584c2f3c74bcf064af36336c10d2e21a2131e123ce0f924c9"}, - {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ab2c2a26d2f69cdf833174f4d9d86118edc781ad9a8fa13970b527bf8236027"}, - {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e8921a259f54bfbc755c5bbd60c82bb2339ae0324163f32868f63f0ebb873d9"}, - {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a7ff941004d74d55a47f916afc38494bd1cfd4b53c482b77c03147c91ac0ac3"}, - {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5145282a7cd2ac16ea0dc46b82167754d5e103a05614b724457cffe614f25bd8"}, - {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de609a6f1b682f70bb7163da745ee815d8f230d97276db049ab447767466a09d"}, - {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40c91c6e34cf016fa8e6b59d75e3dbe354830777fcfd74c58b279dceb7975b75"}, - {file = "rpds_py-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d2132377f9deef0c4db89e65e8bb28644ff75a18df5293e132a8d67748397b9f"}, - {file = "rpds_py-0.21.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0a9e0759e7be10109645a9fddaaad0619d58c9bf30a3f248a2ea57a7c417173a"}, - {file = "rpds_py-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e20da3957bdf7824afdd4b6eeb29510e83e026473e04952dca565170cd1ecc8"}, - {file = "rpds_py-0.21.0-cp311-none-win32.whl", hash = "sha256:f71009b0d5e94c0e86533c0b27ed7cacc1239cb51c178fd239c3cfefefb0400a"}, - {file = "rpds_py-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:e168afe6bf6ab7ab46c8c375606298784ecbe3ba31c0980b7dcbb9631dcba97e"}, - {file = "rpds_py-0.21.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:30b912c965b2aa76ba5168fd610087bad7fcde47f0a8367ee8f1876086ee6d1d"}, - {file = "rpds_py-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca9989d5d9b1b300bc18e1801c67b9f6d2c66b8fd9621b36072ed1df2c977f72"}, - {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f54e7106f0001244a5f4cf810ba8d3f9c542e2730821b16e969d6887b664266"}, - {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fed5dfefdf384d6fe975cc026886aece4f292feaf69d0eeb716cfd3c5a4dd8be"}, - {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590ef88db231c9c1eece44dcfefd7515d8bf0d986d64d0caf06a81998a9e8cab"}, - {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f983e4c2f603c95dde63df633eec42955508eefd8d0f0e6d236d31a044c882d7"}, - {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b229ce052ddf1a01c67d68166c19cb004fb3612424921b81c46e7ea7ccf7c3bf"}, - {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ebf64e281a06c904a7636781d2e973d1f0926a5b8b480ac658dc0f556e7779f4"}, - {file = "rpds_py-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:998a8080c4495e4f72132f3d66ff91f5997d799e86cec6ee05342f8f3cda7dca"}, - {file = "rpds_py-0.21.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:98486337f7b4f3c324ab402e83453e25bb844f44418c066623db88e4c56b7c7b"}, - {file = "rpds_py-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a78d8b634c9df7f8d175451cfeac3810a702ccb85f98ec95797fa98b942cea11"}, - {file = "rpds_py-0.21.0-cp312-none-win32.whl", hash = "sha256:a58ce66847711c4aa2ecfcfaff04cb0327f907fead8945ffc47d9407f41ff952"}, - {file = "rpds_py-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:e860f065cc4ea6f256d6f411aba4b1251255366e48e972f8a347cf88077b24fd"}, - {file = "rpds_py-0.21.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ee4eafd77cc98d355a0d02f263efc0d3ae3ce4a7c24740010a8b4012bbb24937"}, - {file = "rpds_py-0.21.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:688c93b77e468d72579351a84b95f976bd7b3e84aa6686be6497045ba84be560"}, - {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c38dbf31c57032667dd5a2f0568ccde66e868e8f78d5a0d27dcc56d70f3fcd3b"}, - {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d6129137f43f7fa02d41542ffff4871d4aefa724a5fe38e2c31a4e0fd343fb0"}, - {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520ed8b99b0bf86a176271f6fe23024323862ac674b1ce5b02a72bfeff3fff44"}, - {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaeb25ccfb9b9014a10eaf70904ebf3f79faaa8e60e99e19eef9f478651b9b74"}, - {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af04ac89c738e0f0f1b913918024c3eab6e3ace989518ea838807177d38a2e94"}, - {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b9b76e2afd585803c53c5b29e992ecd183f68285b62fe2668383a18e74abe7a3"}, - {file = "rpds_py-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5afb5efde74c54724e1a01118c6e5c15e54e642c42a1ba588ab1f03544ac8c7a"}, - {file = "rpds_py-0.21.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:52c041802a6efa625ea18027a0723676a778869481d16803481ef6cc02ea8cb3"}, - {file = "rpds_py-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee1e4fc267b437bb89990b2f2abf6c25765b89b72dd4a11e21934df449e0c976"}, - {file = "rpds_py-0.21.0-cp313-none-win32.whl", hash = "sha256:0c025820b78817db6a76413fff6866790786c38f95ea3f3d3c93dbb73b632202"}, - {file = "rpds_py-0.21.0-cp313-none-win_amd64.whl", hash = "sha256:320c808df533695326610a1b6a0a6e98f033e49de55d7dc36a13c8a30cfa756e"}, - {file = "rpds_py-0.21.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2c51d99c30091f72a3c5d126fad26236c3f75716b8b5e5cf8effb18889ced928"}, - {file = "rpds_py-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbd7504a10b0955ea287114f003b7ad62330c9e65ba012c6223dba646f6ffd05"}, - {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dcc4949be728ede49e6244eabd04064336012b37f5c2200e8ec8eb2988b209c"}, - {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f414da5c51bf350e4b7960644617c130140423882305f7574b6cf65a3081cecb"}, - {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9afe42102b40007f588666bc7de82451e10c6788f6f70984629db193849dced1"}, - {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b929c2bb6e29ab31f12a1117c39f7e6d6450419ab7464a4ea9b0b417174f044"}, - {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8404b3717da03cbf773a1d275d01fec84ea007754ed380f63dfc24fb76ce4592"}, - {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e12bb09678f38b7597b8346983d2323a6482dcd59e423d9448108c1be37cac9d"}, - {file = "rpds_py-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58a0e345be4b18e6b8501d3b0aa540dad90caeed814c515e5206bb2ec26736fd"}, - {file = "rpds_py-0.21.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c3761f62fcfccf0864cc4665b6e7c3f0c626f0380b41b8bd1ce322103fa3ef87"}, - {file = "rpds_py-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c2b2f71c6ad6c2e4fc9ed9401080badd1469fa9889657ec3abea42a3d6b2e1ed"}, - {file = "rpds_py-0.21.0-cp39-none-win32.whl", hash = "sha256:b21747f79f360e790525e6f6438c7569ddbfb1b3197b9e65043f25c3c9b489d8"}, - {file = "rpds_py-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:0626238a43152918f9e72ede9a3b6ccc9e299adc8ade0d67c5e142d564c9a83d"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6b4ef7725386dc0762857097f6b7266a6cdd62bfd209664da6712cb26acef035"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6bc0e697d4d79ab1aacbf20ee5f0df80359ecf55db33ff41481cf3e24f206919"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da52d62a96e61c1c444f3998c434e8b263c384f6d68aca8274d2e08d1906325c"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98e4fe5db40db87ce1c65031463a760ec7906ab230ad2249b4572c2fc3ef1f9f"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30bdc973f10d28e0337f71d202ff29345320f8bc49a31c90e6c257e1ccef4333"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:faa5e8496c530f9c71f2b4e1c49758b06e5f4055e17144906245c99fa6d45356"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32eb88c30b6a4f0605508023b7141d043a79b14acb3b969aa0b4f99b25bc7d4a"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a89a8ce9e4e75aeb7fa5d8ad0f3fecdee813802592f4f46a15754dcb2fd6b061"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:241e6c125568493f553c3d0fdbb38c74babf54b45cef86439d4cd97ff8feb34d"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:3b766a9f57663396e4f34f5140b3595b233a7b146e94777b97a8413a1da1be18"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:af4a644bf890f56e41e74be7d34e9511e4954894d544ec6b8efe1e21a1a8da6c"}, - {file = "rpds_py-0.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3e30a69a706e8ea20444b98a49f386c17b26f860aa9245329bab0851ed100677"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:031819f906bb146561af051c7cef4ba2003d28cff07efacef59da973ff7969ba"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b876f2bc27ab5954e2fd88890c071bd0ed18b9c50f6ec3de3c50a5ece612f7a6"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc5695c321e518d9f03b7ea6abb5ea3af4567766f9852ad1560f501b17588c7b"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4de1da871b5c0fd5537b26a6fc6814c3cc05cabe0c941db6e9044ffbb12f04a"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:878f6fea96621fda5303a2867887686d7a198d9e0f8a40be100a63f5d60c88c9"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8eeec67590e94189f434c6d11c426892e396ae59e4801d17a93ac96b8c02a6c"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ff2eba7f6c0cb523d7e9cff0903f2fe1feff8f0b2ceb6bd71c0e20a4dcee271"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a429b99337062877d7875e4ff1a51fe788424d522bd64a8c0a20ef3021fdb6ed"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d167e4dbbdac48bd58893c7e446684ad5d425b407f9336e04ab52e8b9194e2ed"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:4eb2de8a147ffe0626bfdc275fc6563aa7bf4b6db59cf0d44f0ccd6ca625a24e"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e78868e98f34f34a88e23ee9ccaeeec460e4eaf6db16d51d7a9b883e5e785a5e"}, - {file = "rpds_py-0.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4991ca61656e3160cdaca4851151fd3f4a92e9eba5c7a530ab030d6aee96ec89"}, - {file = "rpds_py-0.21.0.tar.gz", hash = "sha256:ed6378c9d66d0de903763e7706383d60c33829581f0adff47b6535f1802fa6db"}, -] - -[[package]] -name = "send2trash" -version = "1.8.3" -description = "Send file to trash natively under Mac OS X, Windows and Linux" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -files = [ - {file = "Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9"}, - {file = "Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf"}, -] - -[package.extras] -nativelib = ["pyobjc-framework-Cocoa", "pywin32"] -objc = ["pyobjc-framework-Cocoa"] -win32 = ["pywin32"] - -[[package]] -name = "setuptools" -version = "75.5.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -files = [ - {file = "setuptools-75.5.0-py3-none-any.whl", hash = "sha256:87cb777c3b96d638ca02031192d40390e0ad97737e27b6b4fa831bea86f2f829"}, - {file = "setuptools-75.5.0.tar.gz", hash = "sha256:5c4ccb41111392671f02bb5f8436dfc5a9a7185e80500531b133f5775c4163ef"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"] -core = ["importlib-metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "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)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "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)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "soupsieve" -version = "2.6" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.8" -files = [ - {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, - {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.36" -description = "Database Abstraction Library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b8f3adb3971929a3e660337f5dacc5942c2cdb760afcabb2614ffbda9f9f72"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37350015056a553e442ff672c2d20e6f4b6d0b2495691fa239d8aa18bb3bc908"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8318f4776c85abc3f40ab185e388bee7a6ea99e7fa3a30686580b209eaa35c08"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c245b1fbade9c35e5bd3b64270ab49ce990369018289ecfde3f9c318411aaa07"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:69f93723edbca7342624d09f6704e7126b152eaed3cdbb634cb657a54332a3c5"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f9511d8dd4a6e9271d07d150fb2f81874a3c8c95e11ff9af3a2dfc35fe42ee44"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-win32.whl", hash = "sha256:c3f3631693003d8e585d4200730616b78fafd5a01ef8b698f6967da5c605b3fa"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-win_amd64.whl", hash = "sha256:a86bfab2ef46d63300c0f06936bd6e6c0105faa11d509083ba8f2f9d237fb5b5"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd3a55deef00f689ce931d4d1b23fa9f04c880a48ee97af488fd215cf24e2a6c"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f5e9cd989b45b73bd359f693b935364f7e1f79486e29015813c338450aa5a71"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ddd9db6e59c44875211bc4c7953a9f6638b937b0a88ae6d09eb46cced54eff"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2519f3a5d0517fc159afab1015e54bb81b4406c278749779be57a569d8d1bb0d"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59b1ee96617135f6e1d6f275bbe988f419c5178016f3d41d3c0abb0c819f75bb"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39769a115f730d683b0eb7b694db9789267bcd027326cccc3125e862eb03bfd8"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-win32.whl", hash = "sha256:66bffbad8d6271bb1cc2f9a4ea4f86f80fe5e2e3e501a5ae2a3dc6a76e604e6f"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-win_amd64.whl", hash = "sha256:23623166bfefe1487d81b698c423f8678e80df8b54614c2bf4b4cfcd7c711959"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:be9812b766cad94a25bc63bec11f88c4ad3629a0cec1cd5d4ba48dc23860486b"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aae840ebbd6cdd41af1c14590e5741665e5272d2fee999306673a1bb1fdb4d"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4557e1f11c5f653ebfdd924f3f9d5ebfc718283b0b9beebaa5dd6b77ec290971"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07b441f7d03b9a66299ce7ccf3ef2900abc81c0db434f42a5694a37bd73870f2"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:28120ef39c92c2dd60f2721af9328479516844c6b550b077ca450c7d7dc68575"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-win32.whl", hash = "sha256:b81ee3d84803fd42d0b154cb6892ae57ea6b7c55d8359a02379965706c7efe6c"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-win_amd64.whl", hash = "sha256:f942a799516184c855e1a32fbc7b29d7e571b52612647866d4ec1c3242578fcb"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3d6718667da04294d7df1670d70eeddd414f313738d20a6f1d1f379e3139a545"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:72c28b84b174ce8af8504ca28ae9347d317f9dba3999e5981a3cd441f3712e24"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b11d0cfdd2b095e7b0686cf5fabeb9c67fae5b06d265d8180715b8cfa86522e3"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e32092c47011d113dc01ab3e1d3ce9f006a47223b18422c5c0d150af13a00687"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6a440293d802d3011028e14e4226da1434b373cbaf4a4bbb63f845761a708346"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c54a1e53a0c308a8e8a7dffb59097bff7facda27c70c286f005327f21b2bd6b1"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-win32.whl", hash = "sha256:1e0d612a17581b6616ff03c8e3d5eff7452f34655c901f75d62bd86449d9750e"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-win_amd64.whl", hash = "sha256:8958b10490125124463095bbdadda5aa22ec799f91958e410438ad6c97a7b793"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc022184d3e5cacc9579e41805a681187650e170eb2fd70e28b86192a479dcaa"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b817d41d692bf286abc181f8af476c4fbef3fd05e798777492618378448ee689"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e46a888b54be23d03a89be510f24a7652fe6ff660787b96cd0e57a4ebcb46d"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4ae3005ed83f5967f961fd091f2f8c5329161f69ce8480aa8168b2d7fe37f06"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03e08af7a5f9386a43919eda9de33ffda16b44eb11f3b313e6822243770e9763"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb986bad3ed5ceaf090200eba750b5245150bd97d3e67343a3cfed06feecf7"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-win32.whl", hash = "sha256:9fe53b404f24789b5ea9003fc25b9a3988feddebd7e7b369c8fac27ad6f52f28"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-win_amd64.whl", hash = "sha256:af148a33ff0349f53512a049c6406923e4e02bf2f26c5fb285f143faf4f0e46a"}, - {file = "SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e"}, - {file = "sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5"}, -] - -[package.dependencies] -greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (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 (!=0.4.17)"] -aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] -aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (!=0.4.17)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] -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 (!=0.4.17)"] -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-schemadisplay" -version = "2.0" -description = "Package for the generation of diagrams based on SQLAlchemy ORM models and or the database itself" -optional = false -python-versions = ">=3.8" -files = [ - {file = "sqlalchemy_schemadisplay-2.0-py3-none-any.whl", hash = "sha256:e4b928e2aec145f72a2b35de7855a78fca5e09ac4d48f2d58b4472cb640cd362"}, - {file = "sqlalchemy_schemadisplay-2.0.tar.gz", hash = "sha256:e90b9c9868814975d674a889aadb7c4651658f0e119e1c9320279ea527744d5e"}, -] - -[package.dependencies] -Pillow = "*" -pydot = "*" -setuptools = "*" -sqlalchemy = ">=2.0,<3" - -[package.extras] -pre-commit = ["pre-commit", "tox (>=3.23.0)", "virtualenv (>20)"] -testing = ["attrs (>=17.4.0)", "coverage", "pgtest", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"] - -[[package]] -name = "sqlmodel" -version = "0.0.22" -description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." -optional = false -python-versions = ">=3.7" -files = [ - {file = "sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b"}, - {file = "sqlmodel-0.0.22.tar.gz", hash = "sha256:7d37c882a30c43464d143e35e9ecaf945d88035e20117bf5ec2834a23cbe505e"}, -] - -[package.dependencies] -pydantic = ">=1.10.13,<3.0.0" -SQLAlchemy = ">=2.0.14,<2.1.0" - -[[package]] -name = "stack-data" -version = "0.6.3" -description = "Extract data from python stack frames and tracebacks for informative displays" -optional = false -python-versions = "*" -files = [ - {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, - {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, -] - -[package.dependencies] -asttokens = ">=2.1.0" -executing = ">=1.2.0" -pure-eval = "*" - -[package.extras] -tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] - -[[package]] -name = "starlette" -version = "0.41.2" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.8" -files = [ - {file = "starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d"}, - {file = "starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62"}, -] - -[package.dependencies] -anyio = ">=3.4.0,<5" - -[package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] - -[[package]] -name = "terminado" -version = "0.18.1" -description = "Tornado websocket backend for the Xterm.js Javascript terminal emulator library." -optional = false -python-versions = ">=3.8" -files = [ - {file = "terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0"}, - {file = "terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e"}, -] - -[package.dependencies] -ptyprocess = {version = "*", markers = "os_name != \"nt\""} -pywinpty = {version = ">=1.1.0", markers = "os_name == \"nt\""} -tornado = ">=6.1.0" - -[package.extras] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] -test = ["pre-commit", "pytest (>=7.0)", "pytest-timeout"] -typing = ["mypy (>=1.6,<2.0)", "traitlets (>=5.11.1)"] - -[[package]] -name = "tinycss2" -version = "1.4.0" -description = "A tiny CSS parser" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289"}, - {file = "tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7"}, -] - -[package.dependencies] -webencodings = ">=0.4" - -[package.extras] -doc = ["sphinx", "sphinx_rtd_theme"] -test = ["pytest", "ruff"] - -[[package]] -name = "tornado" -version = "6.4.2" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">=3.8" -files = [ - {file = "tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1"}, - {file = "tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803"}, - {file = "tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec"}, - {file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946"}, - {file = "tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf"}, - {file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634"}, - {file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73"}, - {file = "tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c"}, - {file = "tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482"}, - {file = "tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38"}, - {file = "tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b"}, -] - -[[package]] -name = "traitlets" -version = "5.14.3" -description = "Traitlets Python configuration system" -optional = false -python-versions = ">=3.8" -files = [ - {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, - {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, -] - -[package.extras] -docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] -test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] - -[[package]] -name = "types-python-dateutil" -version = "2.9.0.20241003" -description = "Typing stubs for python-dateutil" -optional = false -python-versions = ">=3.8" -files = [ - {file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"}, - {file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "uri-template" -version = "1.3.0" -description = "RFC 6570 URI Template Processor" -optional = false -python-versions = ">=3.7" -files = [ - {file = "uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7"}, - {file = "uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363"}, -] - -[package.extras] -dev = ["flake8", "flake8-annotations", "flake8-bandit", "flake8-bugbear", "flake8-commas", "flake8-comprehensions", "flake8-continuation", "flake8-datetimez", "flake8-docstrings", "flake8-import-order", "flake8-literal", "flake8-modern-annotations", "flake8-noqa", "flake8-pyproject", "flake8-requirements", "flake8-typechecking-import", "flake8-use-fstring", "mypy", "pep8-naming", "types-PyYAML"] - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "uvicorn" -version = "0.32.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.8" -files = [ - {file = "uvicorn-0.32.0-py3-none-any.whl", hash = "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82"}, - {file = "uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "wcwidth" -version = "0.2.13" -description = "Measures the displayed width of unicode strings in a terminal" -optional = false -python-versions = "*" -files = [ - {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, - {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, -] - -[[package]] -name = "webcolors" -version = "24.11.1" -description = "A library for working with the color formats defined by HTML and CSS." -optional = false -python-versions = ">=3.9" -files = [ - {file = "webcolors-24.11.1-py3-none-any.whl", hash = "sha256:515291393b4cdf0eb19c155749a096f779f7d909f7cceea072791cb9095b92e9"}, - {file = "webcolors-24.11.1.tar.gz", hash = "sha256:ecb3d768f32202af770477b8b65f318fa4f566c22948673a977b00d589dd80f6"}, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -description = "Character encoding aliases for legacy web content" -optional = false -python-versions = "*" -files = [ - {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, - {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, -] - -[[package]] -name = "websocket-client" -version = "1.8.0" -description = "WebSocket client for Python with low level API options" -optional = false -python-versions = ">=3.8" -files = [ - {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, - {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, -] - -[package.extras] -docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] -optional = ["python-socks", "wsaccel"] -test = ["websockets"] - -[[package]] -name = "widgetsnbextension" -version = "4.0.13" -description = "Jupyter interactive widgets for Jupyter Notebook" -optional = false -python-versions = ">=3.7" -files = [ - {file = "widgetsnbextension-4.0.13-py3-none-any.whl", hash = "sha256:74b2692e8500525cc38c2b877236ba51d34541e6385eeed5aec15a70f88a6c71"}, - {file = "widgetsnbextension-4.0.13.tar.gz", hash = "sha256:ffcb67bc9febd10234a362795f643927f4e0c05d9342c727b65d2384f8feacb6"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.12" -content-hash = "2b9406820caa1e7f7514a584d3097ca0bce7254e50caa642c2b4647acde694d6" diff --git a/pyproject.toml b/pyproject.toml index 780ad77..1865278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,35 +1,34 @@ -[tool.poetry] +[project] name = "fastapi-jinja2-postgres-webapp" version = "0.1.0" description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth" -authors = ["Christopher Carroll Smith "] readme = "README.md" package-mode = false +authors = [ + {name = "Christopher Carroll Smith", email = "chriscarrollsmith@gmail.com"}, +] +requires-python = "<4.0,>=3.12" +dependencies = [ + "sqlmodel<1.0.0,>=0.0.22", + "pyjwt<3.0.0,>=2.10.1", + "jinja2<4.0.0,>=3.1.4", + "uvicorn<1.0.0,>=0.32.0", + "psycopg2<3.0.0,>=2.9.10", + "pydantic[email]<3.0.0,>=2.9.2", + "python-multipart<1.0.0,>=0.0.17", + "python-dotenv<2.0.0,>=1.0.1", + "resend<3.0.0,>=2.4.0", + "bcrypt<5.0.0,>=4.2.0", + "fastapi<1.0.0,>=0.115.5", +] -[tool.poetry.dependencies] -python = "^3.12" -sqlmodel = "^0.0.22" -pyjwt = "^2.10.1" -jinja2 = "^3.1.4" -uvicorn = "^0.32.0" -psycopg2 = "^2.9.10" -pydantic = {extras = ["email"], version = "^2.9.2"} -python-multipart = "^0.0.17" -python-dotenv = "^1.0.1" -resend = "^2.4.0" -bcrypt = "^4.2.0" -fastapi = "^0.115.5" - - -[tool.poetry.group.dev.dependencies] -graphviz = "^0.20.3" -quarto = "^0.1.0" -mypy = "^1.11.2" -jupyter = "^1.1.1" -notebook = "^7.2.2" -pytest = "^8.3.3" -sqlalchemy-schemadisplay = "^2.0" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[dependency-groups] +dev = [ + "graphviz<1.0.0,>=0.20.3", + "quarto<1.0.0,>=0.1.0", + "mypy<2.0.0,>=1.11.2", + "jupyter<2.0.0,>=1.1.1", + "notebook<8.0.0,>=7.2.2", + "pytest<9.0.0,>=8.3.3", + "sqlalchemy-schemadisplay<3.0,>=2.0", +] diff --git a/routers/authentication.py b/routers/authentication.py index 0832954..7583ec1 100644 --- a/routers/authentication.py +++ b/routers/authentication.py @@ -26,7 +26,7 @@ router = APIRouter(prefix="/auth", tags=["auth"]) -# -- Server Request and Response Models -- +# --- Server Request and Response Models --- class UserRegister(BaseModel): @@ -102,7 +102,7 @@ async def as_form( new_password=new_password, confirm_new_password=confirm_new_password) -# -- DB Request and Response Models -- +# --- DB Request and Response Models --- class UserRead(BaseModel): @@ -116,7 +116,7 @@ class UserRead(BaseModel): updated_at: datetime -# -- Routes -- +# --- Routes --- # TODO: Use custom error message in the case where the user is already registered diff --git a/routers/organization.py b/routers/organization.py index daa8504..c937ae3 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -10,7 +10,7 @@ logger = getLogger("uvicorn.error") -# -- Custom Exceptions -- +# --- Custom Exceptions --- class EmptyOrganizationNameError(HTTPException): @@ -40,7 +40,7 @@ def __init__(self): router = APIRouter(prefix="/organizations", tags=["organizations"]) -# -- Server Request and Response Models -- +# --- Server Request and Response Models --- class OrganizationCreate(BaseModel): @@ -83,7 +83,7 @@ async def as_form(cls, id: int = Form(...), name: str = Form(...)): return cls(id=id, name=name) -# -- Routes -- +# --- Routes --- @router.post("/create", response_class=RedirectResponse) def create_organization( diff --git a/routers/role.py b/routers/role.py index 1a89f2d..1b5a151 100644 --- a/routers/role.py +++ b/routers/role.py @@ -16,7 +16,7 @@ router = APIRouter(prefix="/roles", tags=["roles"]) -# -- Custom Exceptions -- +# --- Custom Exceptions --- class InvalidPermissionError(HTTPException): @@ -53,7 +53,7 @@ def __init__(self): ) -# -- Server Request Models -- +# --- Server Request Models --- class RoleCreate(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -126,7 +126,7 @@ async def as_form( return cls(id=id, organization_id=organization_id) -# -- Routes -- +# --- Routes --- @router.post("/create", response_class=RedirectResponse) diff --git a/routers/user.py b/routers/user.py index 5639d79..2188c10 100644 --- a/routers/user.py +++ b/routers/user.py @@ -8,7 +8,7 @@ router = APIRouter(prefix="/user", tags=["user"]) -# -- Server Request and Response Models -- +# --- Server Request and Response Models --- class UpdateProfile(BaseModel): @@ -38,7 +38,7 @@ async def as_form( return cls(confirm_delete_password=confirm_delete_password) -# -- Routes -- +# --- Routes --- @router.post("/update_profile", response_class=RedirectResponse) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9d54f78 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1891 @@ +version = 1 +requires-python = ">=3.12, <4.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, +] + +[[package]] +name = "argon2-cffi" +version = "23.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/fa/57ec2c6d16ecd2ba0cf15f3c7d1c3c2e7b5fcb83555ff56d7ab10888ec8f/argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08", size = 42798 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/6a/e8a041599e78b6b3752da48000b14c8d1e8a04ded09c88c714ba047f34f5/argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea", size = 15124 }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/13/838ce2620025e9666aa8f686431f67a29052241692a3dd1ae9d3692a89d3/argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367", size = 29658 }, + { url = "https://files.pythonhosted.org/packages/b3/02/f7f7bb6b6af6031edb11037639c697b912e1dea2db94d436e681aea2f495/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d", size = 80583 }, + { url = "https://files.pythonhosted.org/packages/ec/f7/378254e6dd7ae6f31fe40c8649eea7d4832a42243acaf0f1fff9083b2bed/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae", size = 86168 }, + { url = "https://files.pythonhosted.org/packages/74/f6/4a34a37a98311ed73bb80efe422fed95f2ac25a4cacc5ae1d7ae6a144505/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c", size = 82709 }, + { url = "https://files.pythonhosted.org/packages/74/2b/73d767bfdaab25484f7e7901379d5f8793cccbb86c6e0cbc4c1b96f63896/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86", size = 83613 }, + { url = "https://files.pythonhosted.org/packages/4f/fd/37f86deef67ff57c76f137a67181949c2d408077e2e3dd70c6c42912c9bf/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f", size = 84583 }, + { url = "https://files.pythonhosted.org/packages/6f/52/5a60085a3dae8fded8327a4f564223029f5f54b0cb0455a31131b5363a01/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e", size = 88475 }, + { url = "https://files.pythonhosted.org/packages/8b/95/143cd64feb24a15fa4b189a3e1e7efbaeeb00f39a51e99b26fc62fbacabd/argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082", size = 27698 }, + { url = "https://files.pythonhosted.org/packages/37/2c/e34e47c7dee97ba6f01a6203e0383e15b60fb85d78ac9a15cd066f6fe28b/argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f", size = 30817 }, + { url = "https://files.pythonhosted.org/packages/5a/e4/bf8034d25edaa495da3c8a3405627d2e35758e44ff6eaa7948092646fdcc/argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93", size = 53104 }, +] + +[[package]] +name = "arrow" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "types-python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/00/0f6e8fcdb23ea632c866620cc872729ff43ed91d284c866b515c6342b173/arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85", size = 131960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/ed/e97229a566617f2ae958a6b13e7cc0f585470eac730a73e9e82c32a3cdd2/arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80", size = 66419 }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "async-lru" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/e2/2b4651eff771f6fd900d233e175ddc5e2be502c7eb62c0c42f975c6d36cd/async-lru-2.0.4.tar.gz", hash = "sha256:b8a59a5df60805ff63220b2a0c5b5393da5521b113cd5465a44eb037d81a5627", size = 10019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9f/3c3503693386c4b0f245eaf5ca6198e3b28879ca0a40bde6b0e319793453/async_lru-2.0.4-py3-none-any.whl", hash = "sha256:ff02944ce3c288c5be660c42dbcca0742b32c3b279d6dceda655190240b99224", size = 6111 }, +] + +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + +[[package]] +name = "babel" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, +] + +[[package]] +name = "bcrypt" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/8c/dd696962612e4cd83c40a9e6b3db77bfe65a830f4b9af44098708584686c/bcrypt-4.2.1.tar.gz", hash = "sha256:6765386e3ab87f569b276988742039baab087b2cdb01e809d74e74503c2faafe", size = 24427 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/ca/e17b08c523adb93d5f07a226b2bd45a7c6e96b359e31c1e99f9db58cb8c3/bcrypt-4.2.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:1340411a0894b7d3ef562fb233e4b6ed58add185228650942bdc885362f32c17", size = 489982 }, + { url = "https://files.pythonhosted.org/packages/6a/be/e7c6e0fd6087ee8fc6d77d8d9e817e9339d879737509019b9a9012a1d96f/bcrypt-4.2.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ee315739bc8387aa36ff127afc99120ee452924e0df517a8f3e4c0187a0f5f", size = 273108 }, + { url = "https://files.pythonhosted.org/packages/d6/53/ac084b7d985aee1a5f2b086d501f550862596dbf73220663b8c17427e7f2/bcrypt-4.2.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dbd0747208912b1e4ce730c6725cb56c07ac734b3629b60d4398f082ea718ad", size = 278733 }, + { url = "https://files.pythonhosted.org/packages/8e/ab/b8710a3d6231c587e575ead0b1c45bb99f5454f9f579c9d7312c17b069cc/bcrypt-4.2.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:aaa2e285be097050dba798d537b6efd9b698aa88eef52ec98d23dcd6d7cf6fea", size = 273856 }, + { url = "https://files.pythonhosted.org/packages/9d/e5/2fd1ea6395358ffdfd4afe370d5b52f71408f618f781772a48971ef3b92b/bcrypt-4.2.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76d3e352b32f4eeb34703370e370997065d28a561e4a18afe4fef07249cb4396", size = 279067 }, + { url = "https://files.pythonhosted.org/packages/4e/ef/f2cb7a0f7e1ed800a604f8ab256fb0afcf03c1540ad94ff771ce31e794aa/bcrypt-4.2.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b7703ede632dc945ed1172d6f24e9f30f27b1b1a067f32f68bf169c5f08d0425", size = 306851 }, + { url = "https://files.pythonhosted.org/packages/de/cb/578b0023c6a5ca16a177b9044ba6bd6032277bd3ef020fb863eccd22e49b/bcrypt-4.2.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89df2aea2c43be1e1fa066df5f86c8ce822ab70a30e4c210968669565c0f4685", size = 310793 }, + { url = "https://files.pythonhosted.org/packages/98/bc/9d501ee9d754f63d4b1086b64756c284facc3696de9b556c146279a124a5/bcrypt-4.2.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e56e3fe8308a88b77e0afd20bec516f74aecf391cdd6e374f15cbed32783d6", size = 320957 }, + { url = "https://files.pythonhosted.org/packages/a1/25/2ec4ce5740abc43182bfc064b9acbbf5a493991246985e8b2bfe231ead64/bcrypt-4.2.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdf3d7530c790432046c40cda41dfee8c83e29482e6a604f8930b9930e94139", size = 339958 }, + { url = "https://files.pythonhosted.org/packages/6d/64/fd67788f64817727897d31e9cdeeeba3941eaad8540733c05c7eac4aa998/bcrypt-4.2.1-cp37-abi3-win32.whl", hash = "sha256:adadd36274510a01f33e6dc08f5824b97c9580583bd4487c564fc4617b328005", size = 160912 }, + { url = "https://files.pythonhosted.org/packages/00/8f/fe834eaa54abbd7cab8607e5020fa3a0557e929555b9e4ca404b4adaab06/bcrypt-4.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:8c458cd103e6c5d1d85cf600e546a639f234964d0228909d8f8dbeebff82d526", size = 152981 }, + { url = "https://files.pythonhosted.org/packages/4a/57/23b46933206daf5384b5397d9878746d2249fe9d45efaa8e1467c87d3048/bcrypt-4.2.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:8ad2f4528cbf0febe80e5a3a57d7a74e6635e41af1ea5675282a33d769fba413", size = 489842 }, + { url = "https://files.pythonhosted.org/packages/fd/28/3ea8a39ddd4938b6c6b6136816d72ba5e659e2d82b53d843c8c53455ac4d/bcrypt-4.2.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:909faa1027900f2252a9ca5dfebd25fc0ef1417943824783d1c8418dd7d6df4a", size = 272500 }, + { url = "https://files.pythonhosted.org/packages/77/7f/b43622999f5d4de06237a195ac5501ac83516adf571b907228cd14bac8fe/bcrypt-4.2.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cde78d385d5e93ece5479a0a87f73cd6fa26b171c786a884f955e165032b262c", size = 278368 }, + { url = "https://files.pythonhosted.org/packages/50/68/f2e3959014b4d8874c747e6e171d46d3e63a3a39aaca8417a8d837eda0a8/bcrypt-4.2.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:533e7f3bcf2f07caee7ad98124fab7499cb3333ba2274f7a36cf1daee7409d99", size = 273335 }, + { url = "https://files.pythonhosted.org/packages/d6/c3/4b4bad4da852924427c651589d464ad1aa624f94dd904ddda8493b0a35e5/bcrypt-4.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:687cf30e6681eeda39548a93ce9bfbb300e48b4d445a43db4298d2474d2a1e54", size = 278614 }, + { url = "https://files.pythonhosted.org/packages/6e/5a/ee107961e84c41af2ac201d0460f962b6622ff391255ffd46429e9e09dc1/bcrypt-4.2.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:041fa0155c9004eb98a232d54da05c0b41d4b8e66b6fc3cb71b4b3f6144ba837", size = 306464 }, + { url = "https://files.pythonhosted.org/packages/5c/72/916e14fa12d2b1d1fc6c26ea195337419da6dd23d0bf53ac61ef3739e5c5/bcrypt-4.2.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f85b1ffa09240c89aa2e1ae9f3b1c687104f7b2b9d2098da4e923f1b7082d331", size = 310674 }, + { url = "https://files.pythonhosted.org/packages/97/92/3dc76d8bfa23300591eec248e950f85bd78eb608c96bd4747ce4cc06acdb/bcrypt-4.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c6f5fa3775966cca251848d4d5393ab016b3afed251163c1436fefdec3b02c84", size = 320577 }, + { url = "https://files.pythonhosted.org/packages/5d/ab/a6c0da5c2cf86600f74402a72b06dfe365e1a1d30783b1bbeec460fd57d1/bcrypt-4.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:807261df60a8b1ccd13e6599c779014a362ae4e795f5c59747f60208daddd96d", size = 339836 }, + { url = "https://files.pythonhosted.org/packages/b4/b4/e75b6e9a72a030a04362034022ebe317c5b735d04db6ad79237101ae4a5c/bcrypt-4.2.1-cp39-abi3-win32.whl", hash = "sha256:b588af02b89d9fad33e5f98f7838bf590d6d692df7153647724a7f20c186f6bf", size = 160911 }, + { url = "https://files.pythonhosted.org/packages/76/b9/d51d34e6cd6d887adddb28a8680a1d34235cc45b9d6e238ce39b98199ca0/bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c", size = 153078 }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925 }, +] + +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 }, +] + +[[package]] +name = "certifi" +version = "2024.12.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 }, +] + +[[package]] +name = "debugpy" +version = "1.8.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/e7/666f4c9b0e24796af50aadc28d36d21c2e01e831a934535f956e09b3650c/debugpy-1.8.11.tar.gz", hash = "sha256:6ad2688b69235c43b020e04fecccdf6a96c8943ca9c2fb340b8adc103c655e57", size = 1640124 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ae/2cf26f3111e9d94384d9c01e9d6170188b0aeda15b60a4ac6457f7c8a26f/debugpy-1.8.11-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:84e511a7545d11683d32cdb8f809ef63fc17ea2a00455cc62d0a4dbb4ed1c308", size = 2498756 }, + { url = "https://files.pythonhosted.org/packages/b0/16/ec551789d547541a46831a19aa15c147741133da188e7e6acf77510545a7/debugpy-1.8.11-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce291a5aca4985d82875d6779f61375e959208cdf09fcec40001e65fb0a54768", size = 4219136 }, + { url = "https://files.pythonhosted.org/packages/72/6f/b2b3ce673c55f882d27a6eb04a5f0c68bcad6b742ac08a86d8392ae58030/debugpy-1.8.11-cp312-cp312-win32.whl", hash = "sha256:28e45b3f827d3bf2592f3cf7ae63282e859f3259db44ed2b129093ca0ac7940b", size = 5224440 }, + { url = "https://files.pythonhosted.org/packages/77/09/b1f05be802c1caef5b3efc042fc6a7cadd13d8118b072afd04a9b9e91e06/debugpy-1.8.11-cp312-cp312-win_amd64.whl", hash = "sha256:44b1b8e6253bceada11f714acf4309ffb98bfa9ac55e4fce14f9e5d4484287a1", size = 5264578 }, + { url = "https://files.pythonhosted.org/packages/2e/66/931dc2479aa8fbf362dc6dcee707d895a84b0b2d7b64020135f20b8db1ed/debugpy-1.8.11-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:8988f7163e4381b0da7696f37eec7aca19deb02e500245df68a7159739bbd0d3", size = 2483651 }, + { url = "https://files.pythonhosted.org/packages/10/07/6c171d0fe6b8d237e35598b742f20ba062511b3a4631938cc78eefbbf847/debugpy-1.8.11-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c1f6a173d1140e557347419767d2b14ac1c9cd847e0b4c5444c7f3144697e4e", size = 4213770 }, + { url = "https://files.pythonhosted.org/packages/89/f1/0711da6ac250d4fe3bf7b3e9b14b4a86e82a98b7825075c07e19bab8da3d/debugpy-1.8.11-cp313-cp313-win32.whl", hash = "sha256:bb3b15e25891f38da3ca0740271e63ab9db61f41d4d8541745cfc1824252cb28", size = 5223911 }, + { url = "https://files.pythonhosted.org/packages/56/98/5e27fa39050749ed460025bcd0034a0a5e78a580a14079b164cc3abdeb98/debugpy-1.8.11-cp313-cp313-win_amd64.whl", hash = "sha256:d8768edcbeb34da9e11bcb8b5c2e0958d25218df7a6e56adf415ef262cd7b6d1", size = 5264166 }, + { url = "https://files.pythonhosted.org/packages/77/0a/d29a5aacf47b4383ed569b8478c02d59ee3a01ad91224d2cff8562410e43/debugpy-1.8.11-py2.py3-none-any.whl", hash = "sha256:0e22f846f4211383e6a416d04b4c13ed174d24cc5d43f5fd52e7821d0ebc8920", size = 5226874 }, +] + +[[package]] +name = "decorator" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, +] + +[[package]] +name = "executing" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/7d45f492c2c4a0e8e0fad57d081a7c8a0286cdd86372b070cca1ec0caa1e/executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab", size = 977485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf", size = 25805 }, +] + +[[package]] +name = "fastapi" +version = "0.115.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843 }, +] + +[[package]] +name = "fastapi-jinja2-postgres-webapp" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "bcrypt" }, + { name = "fastapi" }, + { name = "jinja2" }, + { name = "psycopg2" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "resend" }, + { name = "sqlmodel" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "graphviz" }, + { name = "jupyter" }, + { name = "mypy" }, + { name = "notebook" }, + { name = "pytest" }, + { name = "quarto" }, + { name = "sqlalchemy-schemadisplay" }, +] + +[package.metadata] +requires-dist = [ + { name = "bcrypt", specifier = ">=4.2.0,<5.0.0" }, + { name = "fastapi", specifier = ">=0.115.5,<1.0.0" }, + { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, + { name = "psycopg2", specifier = ">=2.9.10,<3.0.0" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.9.2,<3.0.0" }, + { name = "pyjwt", specifier = ">=2.10.1,<3.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.1,<2.0.0" }, + { name = "python-multipart", specifier = ">=0.0.17,<1.0.0" }, + { name = "resend", specifier = ">=2.4.0,<3.0.0" }, + { name = "sqlmodel", specifier = ">=0.0.22,<1.0.0" }, + { name = "uvicorn", specifier = ">=0.32.0,<1.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "graphviz", specifier = ">=0.20.3,<1.0.0" }, + { name = "jupyter", specifier = ">=1.1.1,<2.0.0" }, + { name = "mypy", specifier = ">=1.11.2,<2.0.0" }, + { name = "notebook", specifier = ">=7.2.2,<8.0.0" }, + { name = "pytest", specifier = ">=8.3.3,<9.0.0" }, + { name = "quarto", specifier = ">=0.1.0,<1.0.0" }, + { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924 }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 }, +] + +[[package]] +name = "graphviz" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126 }, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, + { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, + { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, + { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, + { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, + { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, + { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, + { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, + { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, + { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, + { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, + { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, + { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, + { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, + { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, + { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "platform_system == 'Darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173 }, +] + +[[package]] +name = "ipython" +version = "8.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/8b/710af065ab8ed05649afa5bd1e07401637c9ec9fb7cfda9eac7e91e9fbd4/ipython-8.30.0.tar.gz", hash = "sha256:cb0a405a306d2995a5cbb9901894d240784a9f341394c6ba3f4fe8c6eb89ff6e", size = 5592205 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/f3/1332ba2f682b07b304ad34cad2f003adcfeb349486103f4b632335074a7c/ipython-8.30.0-py3-none-any.whl", hash = "sha256:85ec56a7e20f6c38fce7727dcca699ae4ffc85985aa7b23635a8008f918ae321", size = 820765 }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/4c/dab2a281b07596a5fc220d49827fe6c794c66f1493d7a74f1df0640f2cc5/ipywidgets-8.1.5.tar.gz", hash = "sha256:870e43b1a35656a80c18c9503bbf2d16802db1cb487eec6fab27d683381dde17", size = 116723 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/2d/9c0b76f2f9cc0ebede1b9371b6f317243028ed60b90705863d493bae622e/ipywidgets-8.1.5-py3-none-any.whl", hash = "sha256:3290f526f87ae6e77655555baba4f36681c555b8bdbbff430b70e52c34c86245", size = 139767 }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, +] + +[[package]] +name = "json5" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/3d/bbe62f3d0c05a689c711cff57b2e3ac3d3e526380adb7c781989f075115c/json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559", size = 48202 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049 }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, +] + +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657 }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510 }, +] + +[[package]] +name = "jupyter-core" +version = "5.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/11/b56381fa6c3f4cc5d2cf54a7dbf98ad9aa0b339ef7a601d6053538b079a7/jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9", size = 87629 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 }, +] + +[[package]] +name = "jupyter-events" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/53/7537a1aa558229bb0b1b178d814c9d68a9c697d3aecb808a1cb2646acf1f/jupyter_events-0.10.0.tar.gz", hash = "sha256:670b8229d3cc882ec782144ed22e0d29e1c2d639263f92ca8383e66682845e22", size = 61516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/94/059180ea70a9a326e1815176b2370da56376da347a796f8c4f0b830208ef/jupyter_events-0.10.0-py3-none-any.whl", hash = "sha256:4b72130875e59d57716d327ea70d3ebc3af1944d3717e5a498b8a06c6c159960", size = 18777 }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/b4/3200b0b09c12bc3b72d943d923323c398eff382d1dcc7c0dbc8b74630e40/jupyter-lsp-2.2.5.tar.gz", hash = "sha256:793147a05ad446f809fd53ef1cd19a9f5256fd0a2d6b7ce943a982cb4f545001", size = 48741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/e0/7bd7cff65594fd9936e2f9385701e44574fc7d721331ff676ce440b14100/jupyter_lsp-2.2.5-py3-none-any.whl", hash = "sha256:45fbddbd505f3fbfb0b6cb2f1bc5e15e83ab7c79cd6e89416b248cb3c00c11da", size = 69146 }, +] + +[[package]] +name = "jupyter-server" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/34/88b47749c7fa9358e10eac356c4b97d94a91a67d5c935a73f69bc4a31118/jupyter_server-2.14.2.tar.gz", hash = "sha256:66095021aa9638ced276c248b1d81862e4c50f292d575920bbe960de1c56b12b", size = 719933 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/e1/085edea6187a127ca8ea053eb01f4e1792d778b4d192c74d32eb6730fed6/jupyter_server-2.14.2-py3-none-any.whl", hash = "sha256:47ff506127c2f7851a17bf4713434208fc490955d0e8632e95014a9a9afbeefd", size = 383556 }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/562469734f476159e99a55426d697cbf8e7eb5efe89fb0e0b4f83a3d3459/jupyter_server_terminals-0.5.3.tar.gz", hash = "sha256:5ae0295167220e9ace0edcfdb212afd2b01ee8d179fe6f23c899590e9b8a5269", size = 31430 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/2d/2b32cdbe8d2a602f697a649798554e4f072115438e92249624e532e8aca6/jupyter_server_terminals-0.5.3-py3-none-any.whl", hash = "sha256:41ee0d7dc0ebf2809c668e0fc726dfaf258fcd3e769568996ca731b6194ae9aa", size = 13656 }, +] + +[[package]] +name = "jupyterlab" +version = "4.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/ca/b80ea37f800b7d0b96088dec04d59b4575eb33e59ca1ca19d23885fb6fe6/jupyterlab-4.3.3.tar.gz", hash = "sha256:76fa39e548fdac94dc1204af5956c556f54c785f70ee26aa47ea08eda4d5bbcd", size = 21797278 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ce/6731e54aacbe91daa439ae895763fe9d91952ce96cd0e3f94d8d13229717/jupyterlab-4.3.3-py3-none-any.whl", hash = "sha256:32a8fd30677e734ffcc3916a4758b9dab21b02015b668c60eb36f84357b7d4b1", size = 11665394 }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884 }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/c9/a883ce65eb27905ce77ace410d83587c82ea64dc85a48d1f7ed52bcfa68d/jupyterlab_server-2.27.3.tar.gz", hash = "sha256:eb36caca59e74471988f0ae25c77945610b887f777255aa21f8065def9e51ed4", size = 76173 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/09/2032e7d15c544a0e3cd831c51d77a8ca57f7555b2e1b2922142eddb02a84/jupyterlab_server-2.27.3-py3-none-any.whl", hash = "sha256:e697488f66c3db49df675158a77b3b017520d772c6e1548c7d9bcc5df7944ee4", size = 59700 }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/fa26bbb747a9ea4fca6b01453aa22990d52ab62dd61384f1ac0dc9d4e7ba/jupyterlab_widgets-3.0.13.tar.gz", hash = "sha256:a2966d385328c1942b683a8cd96b89b8dd82c8b8f81dda902bb2bc06d46f5bed", size = 203556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/93/858e87edc634d628e5d752ba944c2833133a28fa87bb093e6832ced36a3e/jupyterlab_widgets-3.0.13-py3-none-any.whl", hash = "sha256:e3cda2c233ce144192f1e29914ad522b2f4c40e77214b0cc97377ca3d323db54", size = 214392 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + +[[package]] +name = "mistune" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/c8/f0173fe3bf85fd891aee2e7bcd8207dfe26c2c683d727c5a6cc3aec7b628/mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8", size = 90840 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/74/c95adcdf032956d9ef6c89a9b8a5152bf73915f8c633f3e3d88d06bd699c/mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205", size = 47958 }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "nbclient" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/db/25929926860ba8a3f6123d2d0a235e558e0e4be7b46e9db063a7dfefa0a2/nbclient-0.10.1.tar.gz", hash = "sha256:3e93e348ab27e712acd46fccd809139e356eb9a31aab641d1a7991a6eb4e6f68", size = 62273 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/1a/ed6d1299b1a00c1af4a033fdee565f533926d819e084caf0d2832f6f87c6/nbclient-0.10.1-py3-none-any.whl", hash = "sha256:949019b9240d66897e442888cfb618f69ef23dc71c01cb5fced8499c2cfc084d", size = 25344 }, +] + +[[package]] +name = "nbconvert" +version = "7.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach" }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "tinycss2" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/e8/ba521a033b21132008e520c28ceb818f9f092da5f0261e94e509401b29f9/nbconvert-7.16.4.tar.gz", hash = "sha256:86ca91ba266b0a448dc96fa6c5b9d98affabde2867b363258703536807f9f7f4", size = 854422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/bb/bb5b6a515d1584aa2fd89965b11db6632e4bdc69495a52374bcc36e56cfa/nbconvert-7.16.4-py3-none-any.whl", hash = "sha256:05873c620fe520b6322bf8a5ad562692343fe3452abda5765c7a34b7d1aa3eb3", size = 257388 }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454 }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, +] + +[[package]] +name = "notebook" +version = "7.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/1f/6c90511ea21b4ed6444e61ec8bb4137cb8c34db0f3b82402094286babbdf/notebook-7.3.1.tar.gz", hash = "sha256:84381c2a82d867517fd25b86e986dae1fe113a70b98f03edff9b94e499fec8fa", size = 12777449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/c4/764078234460706fdd2da68f1715ee42359cb24ee18b70db051cfac38455/notebook-7.3.1-py3-none-any.whl", hash = "sha256:212e1486b2230fe22279043f33c7db5cf9a01d29feb063a85cb139747b7c9483", size = 13162639 }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307 }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pillow" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 }, + { url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 }, + { url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 }, + { url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 }, + { url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 }, + { url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 }, + { url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 }, + { url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 }, + { url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 }, + { url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 }, + { url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 }, + { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 }, + { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 }, + { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 }, + { url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714 }, + { url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514 }, + { url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055 }, + { url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751 }, + { url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378 }, + { url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588 }, + { url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509 }, + { url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791 }, + { url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854 }, + { url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369 }, + { url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703 }, + { url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550 }, + { url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038 }, + { url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197 }, + { url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169 }, + { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "prometheus-client" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/14/7d0f567991f3a9af8d1cd4f619040c93b68f09a02b6d0b6ab1b2d1ded5fe/prometheus_client-0.21.1.tar.gz", hash = "sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb", size = 78551 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/c2/ab7d37426c179ceb9aeb109a85cda8948bb269b7561a0be870cc656eefe4/prometheus_client-0.21.1-py3-none-any.whl", hash = "sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301", size = 54682 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, +] + +[[package]] +name = "psutil" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/10/2a30b13c61e7cf937f4adf90710776b7918ed0a9c434e2c38224732af310/psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a", size = 508565 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/9e/8be43078a171381953cfee33c07c0d628594b5dbfc5157847b85022c2c1b/psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688", size = 247762 }, + { url = "https://files.pythonhosted.org/packages/1d/cb/313e80644ea407f04f6602a9e23096540d9dc1878755f3952ea8d3d104be/psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e", size = 248777 }, + { url = "https://files.pythonhosted.org/packages/65/8e/bcbe2025c587b5d703369b6a75b65d41d1367553da6e3f788aff91eaf5bd/psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38", size = 284259 }, + { url = "https://files.pythonhosted.org/packages/58/4d/8245e6f76a93c98aab285a43ea71ff1b171bcd90c9d238bf81f7021fb233/psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b", size = 287255 }, + { url = "https://files.pythonhosted.org/packages/27/c2/d034856ac47e3b3cdfa9720d0e113902e615f4190d5d1bdb8df4b2015fb2/psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a", size = 288804 }, + { url = "https://files.pythonhosted.org/packages/ea/55/5389ed243c878725feffc0d6a3bc5ef6764312b6fc7c081faaa2cfa7ef37/psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e", size = 250386 }, + { url = "https://files.pythonhosted.org/packages/11/91/87fa6f060e649b1e1a7b19a4f5869709fbf750b7c8c262ee776ec32f3028/psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be", size = 254228 }, +] + +[[package]] +name = "psycopg2" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 }, + { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/0f/27908242621b14e649a84e62b133de45f84c255eecb350ab02979844a788/pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9", size = 786486 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997 }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, + { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, + { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, + { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, + { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, + { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, + { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, + { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, + { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, + { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, + { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, + { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, + { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, + { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, + { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, + { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, + { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, + { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, + { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, + { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, + { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, + { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, + { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, +] + +[[package]] +name = "pydot" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/b8/500a772825c7ca87e4fd69c3bd6740e3375d6792a7065dd92759249f223d/pydot-3.0.3.tar.gz", hash = "sha256:5e009d97b2fff92b7a88f09ec1fd5b163f07f3b10469c927d362471d6faa0d50", size = 168086 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/1b/ef569ac44598b6b24bc0f80d5ac4f811af59d3f0d0d23b0216e014c0ec33/pydot-3.0.3-py3-none-any.whl", hash = "sha256:9b0b3081e0bd362d0c61148da10eb1281ec80089b02a28cf06f9093843986f3d", size = 35784 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/d5/e5aeee5387091148a19e1145f63606619cb5f20b83fccb63efae6474e7b2/pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c", size = 920984 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ec/2eb3cd785efd67806c46c13a17339708ddc346cbb684eade7a6e6f79536a/pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84", size = 106921 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "python-json-logger" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/df/8a6015e77e26250c7cd016ed9487c2e5360e315da149d9663dc71b826237/python_json_logger-3.2.0.tar.gz", hash = "sha256:2c11056458d3f56614480b24e9cb28f7aba69cbfbebddbb77c92f0ec0d4947ab", size = 16160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/a84e771466c68a33eda7efb5a274e4045dfb6ae3dc846ac153b62e14e7bd/python_json_logger-3.2.0-py3-none-any.whl", hash = "sha256:d73522ddcfc6d0461394120feaddea9025dc64bf804d96357dd42fa878cc5fe8", size = 14794 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/19/93bfb43a3c41b1dd0fa1fa66a08286f6467d36d30297a7aaab8c0b176a26/python_multipart-0.0.19.tar.gz", hash = "sha256:905502ef39050557b7a6af411f454bc19526529ca46ae6831508438890ce12cc", size = 36886 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/f4/ddd0fcdc454cf3870153ae16a818256523d31c3c8136e216bc6836ed4cd1/python_multipart-0.0.19-py3-none-any.whl", hash = "sha256:f8d5b0b9c618575bf9df01c684ded1d94a338839bdd8223838afacfb4bb2082d", size = 24448 }, +] + +[[package]] +name = "pywin32" +version = "308" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729 }, + { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015 }, + { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033 }, + { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579 }, + { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056 }, + { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, +] + +[[package]] +name = "pywinpty" +version = "2.0.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/82/90f8750423cba4b9b6c842df227609fb60704482d7abf6dd47e2babc055a/pywinpty-2.0.14.tar.gz", hash = "sha256:18bd9529e4a5daf2d9719aa17788ba6013e594ae94c5a0c27e83df3278b0660e", size = 27769 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/79/759ae767a3b78d340446efd54dd1fe4f7dafa4fc7be96ed757e44bcdba54/pywinpty-2.0.14-cp312-none-win_amd64.whl", hash = "sha256:55dad362ef3e9408ade68fd173e4f9032b3ce08f68cfe7eacb2c263ea1179737", size = 1397207 }, + { url = "https://files.pythonhosted.org/packages/7d/34/b77b3c209bf2eaa6455390c8d5449241637f5957f41636a2204065d52bfa/pywinpty-2.0.14-cp313-none-win_amd64.whl", hash = "sha256:074fb988a56ec79ca90ed03a896d40707131897cefb8f76f926e3834227f2819", size = 1396698 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyzmq" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/05/bed626b9f7bb2322cdbbf7b4bd8f54b1b617b0d2ab2d3547d6e39428a48e/pyzmq-26.2.0.tar.gz", hash = "sha256:070672c258581c8e4f640b5159297580a9974b026043bd4ab0470be9ed324f1f", size = 271975 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/2f/78a766c8913ad62b28581777ac4ede50c6d9f249d39c2963e279524a1bbe/pyzmq-26.2.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ded0fc7d90fe93ae0b18059930086c51e640cdd3baebdc783a695c77f123dcd9", size = 1343105 }, + { url = "https://files.pythonhosted.org/packages/b7/9c/4b1e2d3d4065be715e007fe063ec7885978fad285f87eae1436e6c3201f4/pyzmq-26.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:17bf5a931c7f6618023cdacc7081f3f266aecb68ca692adac015c383a134ca52", size = 1008365 }, + { url = "https://files.pythonhosted.org/packages/4f/ef/5a23ec689ff36d7625b38d121ef15abfc3631a9aecb417baf7a4245e4124/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55cf66647e49d4621a7e20c8d13511ef1fe1efbbccf670811864452487007e08", size = 665923 }, + { url = "https://files.pythonhosted.org/packages/ae/61/d436461a47437d63c6302c90724cf0981883ec57ceb6073873f32172d676/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4661c88db4a9e0f958c8abc2b97472e23061f0bc737f6f6179d7a27024e1faa5", size = 903400 }, + { url = "https://files.pythonhosted.org/packages/47/42/fc6d35ecefe1739a819afaf6f8e686f7f02a4dd241c78972d316f403474c/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea7f69de383cb47522c9c208aec6dd17697db7875a4674c4af3f8cfdac0bdeae", size = 860034 }, + { url = "https://files.pythonhosted.org/packages/07/3b/44ea6266a6761e9eefaa37d98fabefa112328808ac41aa87b4bbb668af30/pyzmq-26.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7f98f6dfa8b8ccaf39163ce872bddacca38f6a67289116c8937a02e30bbe9711", size = 860579 }, + { url = "https://files.pythonhosted.org/packages/38/6f/4df2014ab553a6052b0e551b37da55166991510f9e1002c89cab7ce3b3f2/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3e0210287329272539eea617830a6a28161fbbd8a3271bf4150ae3e58c5d0e6", size = 1196246 }, + { url = "https://files.pythonhosted.org/packages/38/9d/ee240fc0c9fe9817f0c9127a43238a3e28048795483c403cc10720ddef22/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b274e0762c33c7471f1a7471d1a2085b1a35eba5cdc48d2ae319f28b6fc4de3", size = 1507441 }, + { url = "https://files.pythonhosted.org/packages/85/4f/01711edaa58d535eac4a26c294c617c9a01f09857c0ce191fd574d06f359/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29c6a4635eef69d68a00321e12a7d2559fe2dfccfa8efae3ffb8e91cd0b36a8b", size = 1406498 }, + { url = "https://files.pythonhosted.org/packages/07/18/907134c85c7152f679ed744e73e645b365f3ad571f38bdb62e36f347699a/pyzmq-26.2.0-cp312-cp312-win32.whl", hash = "sha256:989d842dc06dc59feea09e58c74ca3e1678c812a4a8a2a419046d711031f69c7", size = 575533 }, + { url = "https://files.pythonhosted.org/packages/ce/2c/a6f4a20202a4d3c582ad93f95ee78d79bbdc26803495aec2912b17dbbb6c/pyzmq-26.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a50625acdc7801bc6f74698c5c583a491c61d73c6b7ea4dee3901bb99adb27a", size = 637768 }, + { url = "https://files.pythonhosted.org/packages/5f/0e/eb16ff731632d30554bf5af4dbba3ffcd04518219d82028aea4ae1b02ca5/pyzmq-26.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d29ab8592b6ad12ebbf92ac2ed2bedcfd1cec192d8e559e2e099f648570e19b", size = 540675 }, + { url = "https://files.pythonhosted.org/packages/04/a7/0f7e2f6c126fe6e62dbae0bc93b1bd3f1099cf7fea47a5468defebe3f39d/pyzmq-26.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9dd8cd1aeb00775f527ec60022004d030ddc51d783d056e3e23e74e623e33726", size = 1006564 }, + { url = "https://files.pythonhosted.org/packages/31/b6/a187165c852c5d49f826a690857684333a6a4a065af0a6015572d2284f6a/pyzmq-26.2.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:28c812d9757fe8acecc910c9ac9dafd2ce968c00f9e619db09e9f8f54c3a68a3", size = 1340447 }, + { url = "https://files.pythonhosted.org/packages/68/ba/f4280c58ff71f321602a6e24fd19879b7e79793fb8ab14027027c0fb58ef/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d80b1dd99c1942f74ed608ddb38b181b87476c6a966a88a950c7dee118fdf50", size = 665485 }, + { url = "https://files.pythonhosted.org/packages/77/b5/c987a5c53c7d8704216f29fc3d810b32f156bcea488a940e330e1bcbb88d/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c997098cc65e3208eca09303630e84d42718620e83b733d0fd69543a9cab9cb", size = 903484 }, + { url = "https://files.pythonhosted.org/packages/29/c9/07da157d2db18c72a7eccef8e684cefc155b712a88e3d479d930aa9eceba/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad1bc8d1b7a18497dda9600b12dc193c577beb391beae5cd2349184db40f187", size = 859981 }, + { url = "https://files.pythonhosted.org/packages/43/09/e12501bd0b8394b7d02c41efd35c537a1988da67fc9c745cae9c6c776d31/pyzmq-26.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bea2acdd8ea4275e1278350ced63da0b166421928276c7c8e3f9729d7402a57b", size = 860334 }, + { url = "https://files.pythonhosted.org/packages/eb/ff/f5ec1d455f8f7385cc0a8b2acd8c807d7fade875c14c44b85c1bddabae21/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:23f4aad749d13698f3f7b64aad34f5fc02d6f20f05999eebc96b89b01262fb18", size = 1196179 }, + { url = "https://files.pythonhosted.org/packages/ec/8a/bb2ac43295b1950fe436a81fc5b298be0b96ac76fb029b514d3ed58f7b27/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a4f96f0d88accc3dbe4a9025f785ba830f968e21e3e2c6321ccdfc9aef755115", size = 1507668 }, + { url = "https://files.pythonhosted.org/packages/a9/49/dbc284ebcfd2dca23f6349227ff1616a7ee2c4a35fe0a5d6c3deff2b4fed/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ced65e5a985398827cc9276b93ef6dfabe0273c23de8c7931339d7e141c2818e", size = 1406539 }, + { url = "https://files.pythonhosted.org/packages/00/68/093cdce3fe31e30a341d8e52a1ad86392e13c57970d722c1f62a1d1a54b6/pyzmq-26.2.0-cp313-cp313-win32.whl", hash = "sha256:31507f7b47cc1ead1f6e86927f8ebb196a0bab043f6345ce070f412a59bf87b5", size = 575567 }, + { url = "https://files.pythonhosted.org/packages/92/ae/6cc4657148143412b5819b05e362ae7dd09fb9fe76e2a539dcff3d0386bc/pyzmq-26.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:70fc7fcf0410d16ebdda9b26cbd8bf8d803d220a7f3522e060a69a9c87bf7bad", size = 637551 }, + { url = "https://files.pythonhosted.org/packages/6c/67/fbff102e201688f97c8092e4c3445d1c1068c2f27bbd45a578df97ed5f94/pyzmq-26.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c3789bd5768ab5618ebf09cef6ec2b35fed88709b104351748a63045f0ff9797", size = 540378 }, + { url = "https://files.pythonhosted.org/packages/3f/fe/2d998380b6e0122c6c4bdf9b6caf490831e5f5e2d08a203b5adff060c226/pyzmq-26.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:034da5fc55d9f8da09015d368f519478a52675e558c989bfcb5cf6d4e16a7d2a", size = 1007378 }, + { url = "https://files.pythonhosted.org/packages/4a/f4/30d6e7157f12b3a0390bde94d6a8567cdb88846ed068a6e17238a4ccf600/pyzmq-26.2.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c92d73464b886931308ccc45b2744e5968cbaade0b1d6aeb40d8ab537765f5bc", size = 1329532 }, + { url = "https://files.pythonhosted.org/packages/82/86/3fe917870e15ee1c3ad48229a2a64458e36036e64b4afa9659045d82bfa8/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:794a4562dcb374f7dbbfb3f51d28fb40123b5a2abadee7b4091f93054909add5", size = 653242 }, + { url = "https://files.pythonhosted.org/packages/50/2d/242e7e6ef6c8c19e6cb52d095834508cd581ffb925699fd3c640cdc758f1/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aee22939bb6075e7afededabad1a56a905da0b3c4e3e0c45e75810ebe3a52672", size = 888404 }, + { url = "https://files.pythonhosted.org/packages/ac/11/7270566e1f31e4ea73c81ec821a4b1688fd551009a3d2bab11ec66cb1e8f/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ae90ff9dad33a1cfe947d2c40cb9cb5e600d759ac4f0fd22616ce6540f72797", size = 845858 }, + { url = "https://files.pythonhosted.org/packages/91/d5/72b38fbc69867795c8711bdd735312f9fef1e3d9204e2f63ab57085434b9/pyzmq-26.2.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:43a47408ac52647dfabbc66a25b05b6a61700b5165807e3fbd40063fcaf46386", size = 847375 }, + { url = "https://files.pythonhosted.org/packages/dd/9a/10ed3c7f72b4c24e719c59359fbadd1a27556a28b36cdf1cd9e4fb7845d5/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:25bf2374a2a8433633c65ccb9553350d5e17e60c8eb4de4d92cc6bd60f01d306", size = 1183489 }, + { url = "https://files.pythonhosted.org/packages/72/2d/8660892543fabf1fe41861efa222455811adac9f3c0818d6c3170a1153e3/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:007137c9ac9ad5ea21e6ad97d3489af654381324d5d3ba614c323f60dab8fae6", size = 1492932 }, + { url = "https://files.pythonhosted.org/packages/7b/d6/32fd69744afb53995619bc5effa2a405ae0d343cd3e747d0fbc43fe894ee/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:470d4a4f6d48fb34e92d768b4e8a5cc3780db0d69107abf1cd7ff734b9766eb0", size = 1392485 }, +] + +[[package]] +name = "quarto" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "jupyter-core" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/a4/33dc28f6fcd5ff80ee80b2afddea9bc46a42f5e2bbc000eeb1b5f6194918/quarto-0.1.0.tar.gz", hash = "sha256:d9a4978110204f5b9d3af39faa9e195083efcbac8f6a23789e29cb27c72ded15", size = 2920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/14/8af79508df038ab05953ecf862420c7f4cc131b1682f0c80cb4f78c58593/quarto-0.1.0-py3-none-any.whl", hash = "sha256:8138fc9d1bee6269a5436837baedc699a262be1478f96444d6f99029f1a114f0", size = 10319 }, +] + +[[package]] +name = "referencing" +version = "0.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/73ca1f8e72fff6fa52119dbd185f73a907b1989428917b24cff660129b6d/referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c", size = 62991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/59/2056f61236782a2c86b33906c025d4f4a0b17be0161b63b70fd9e8775d36/referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de", size = 26684 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "resend" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/50/6ed3bd29137c03f97e71cf0f35796a138ff22b9a2e7e2385c7883fbe0673/resend-2.4.0.tar.gz", hash = "sha256:0f2b06c9afdc5c71f2d2f828dcd6680077b0ef3a3d7420ec37d150857b67a095", size = 11706 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/d1/7d06d0aa061d2954843adcf06e119f7dc20aa7139237d6fdf03bd2c986a6/resend-2.4.0-py2.py3-none-any.whl", hash = "sha256:92b674e4877bca9b65c38bb06b5b29a509f2fa878f147b58872afe380738e448", size = 17201 }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242 }, +] + +[[package]] +name = "rpds-py" +version = "0.22.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/80/cce854d0921ff2f0a9fa831ba3ad3c65cee3a46711addf39a2af52df2cfd/rpds_py-0.22.3.tar.gz", hash = "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d", size = 26771 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/47/3383ee3bd787a2a5e65a9b9edc37ccf8505c0a00170e3a5e6ea5fbcd97f7/rpds_py-0.22.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27e98004595899949bd7a7b34e91fa7c44d7a97c40fcaf1d874168bb652ec67e", size = 352334 }, + { url = "https://files.pythonhosted.org/packages/40/14/aa6400fa8158b90a5a250a77f2077c0d0cd8a76fce31d9f2b289f04c6dec/rpds_py-0.22.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1978d0021e943aae58b9b0b196fb4895a25cc53d3956b8e35e0b7682eefb6d56", size = 342111 }, + { url = "https://files.pythonhosted.org/packages/7d/06/395a13bfaa8a28b302fb433fb285a67ce0ea2004959a027aea8f9c52bad4/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655ca44a831ecb238d124e0402d98f6212ac527a0ba6c55ca26f616604e60a45", size = 384286 }, + { url = "https://files.pythonhosted.org/packages/43/52/d8eeaffab047e6b7b7ef7f00d5ead074a07973968ffa2d5820fa131d7852/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feea821ee2a9273771bae61194004ee2fc33f8ec7db08117ef9147d4bbcbca8e", size = 391739 }, + { url = "https://files.pythonhosted.org/packages/83/31/52dc4bde85c60b63719610ed6f6d61877effdb5113a72007679b786377b8/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bebe05a9ffc70ebfa127efbc429bc26ec9e9b4ee4d15a740033efda515cf3d", size = 427306 }, + { url = "https://files.pythonhosted.org/packages/70/d5/1bab8e389c2261dba1764e9e793ed6830a63f830fdbec581a242c7c46bda/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af6e48651c4e0d2d166dc1b033b7042ea3f871504b6805ba5f4fe31581d8d38", size = 442717 }, + { url = "https://files.pythonhosted.org/packages/82/a1/a45f3e30835b553379b3a56ea6c4eb622cf11e72008229af840e4596a8ea/rpds_py-0.22.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ba3c290821343c192f7eae1d8fd5999ca2dc99994114643e2f2d3e6138b15", size = 385721 }, + { url = "https://files.pythonhosted.org/packages/a6/27/780c942de3120bdd4d0e69583f9c96e179dfff082f6ecbb46b8d6488841f/rpds_py-0.22.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02fbb9c288ae08bcb34fb41d516d5eeb0455ac35b5512d03181d755d80810059", size = 415824 }, + { url = "https://files.pythonhosted.org/packages/94/0b/aa0542ca88ad20ea719b06520f925bae348ea5c1fdf201b7e7202d20871d/rpds_py-0.22.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f56a6b404f74ab372da986d240e2e002769a7d7102cc73eb238a4f72eec5284e", size = 561227 }, + { url = "https://files.pythonhosted.org/packages/0d/92/3ed77d215f82c8f844d7f98929d56cc321bb0bcfaf8f166559b8ec56e5f1/rpds_py-0.22.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0a0461200769ab3b9ab7e513f6013b7a97fdeee41c29b9db343f3c5a8e2b9e61", size = 587424 }, + { url = "https://files.pythonhosted.org/packages/09/42/cacaeb047a22cab6241f107644f230e2935d4efecf6488859a7dd82fc47d/rpds_py-0.22.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8633e471c6207a039eff6aa116e35f69f3156b3989ea3e2d755f7bc41754a4a7", size = 555953 }, + { url = "https://files.pythonhosted.org/packages/e6/52/c921dc6d5f5d45b212a456c1f5b17df1a471127e8037eb0972379e39dff4/rpds_py-0.22.3-cp312-cp312-win32.whl", hash = "sha256:593eba61ba0c3baae5bc9be2f5232430453fb4432048de28399ca7376de9c627", size = 221339 }, + { url = "https://files.pythonhosted.org/packages/f2/c7/f82b5be1e8456600395366f86104d1bd8d0faed3802ad511ef6d60c30d98/rpds_py-0.22.3-cp312-cp312-win_amd64.whl", hash = "sha256:d115bffdd417c6d806ea9069237a4ae02f513b778e3789a359bc5856e0404cc4", size = 235786 }, + { url = "https://files.pythonhosted.org/packages/d0/bf/36d5cc1f2c609ae6e8bf0fc35949355ca9d8790eceb66e6385680c951e60/rpds_py-0.22.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84", size = 351657 }, + { url = "https://files.pythonhosted.org/packages/24/2a/f1e0fa124e300c26ea9382e59b2d582cba71cedd340f32d1447f4f29fa4e/rpds_py-0.22.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25", size = 341829 }, + { url = "https://files.pythonhosted.org/packages/cf/c2/0da1231dd16953845bed60d1a586fcd6b15ceaeb965f4d35cdc71f70f606/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4", size = 384220 }, + { url = "https://files.pythonhosted.org/packages/c7/73/a4407f4e3a00a9d4b68c532bf2d873d6b562854a8eaff8faa6133b3588ec/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b09865a9abc0ddff4e50b5ef65467cd94176bf1e0004184eb915cbc10fc05c5", size = 391009 }, + { url = "https://files.pythonhosted.org/packages/a9/c3/04b7353477ab360fe2563f5f0b176d2105982f97cd9ae80a9c5a18f1ae0f/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3453e8d41fe5f17d1f8e9c383a7473cd46a63661628ec58e07777c2fff7196dc", size = 426989 }, + { url = "https://files.pythonhosted.org/packages/8d/e6/e4b85b722bcf11398e17d59c0f6049d19cd606d35363221951e6d625fcb0/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5d36399a1b96e1a5fdc91e0522544580dbebeb1f77f27b2b0ab25559e103b8b", size = 441544 }, + { url = "https://files.pythonhosted.org/packages/27/fc/403e65e56f65fff25f2973216974976d3f0a5c3f30e53758589b6dc9b79b/rpds_py-0.22.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518", size = 385179 }, + { url = "https://files.pythonhosted.org/packages/57/9b/2be9ff9700d664d51fd96b33d6595791c496d2778cb0b2a634f048437a55/rpds_py-0.22.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1aef18820ef3e4587ebe8b3bc9ba6e55892a6d7b93bac6d29d9f631a3b4befbd", size = 415103 }, + { url = "https://files.pythonhosted.org/packages/bb/a5/03c2ad8ca10994fcf22dd2150dd1d653bc974fa82d9a590494c84c10c641/rpds_py-0.22.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f60bd8423be1d9d833f230fdbccf8f57af322d96bcad6599e5a771b151398eb2", size = 560916 }, + { url = "https://files.pythonhosted.org/packages/ba/2e/be4fdfc8b5b576e588782b56978c5b702c5a2307024120d8aeec1ab818f0/rpds_py-0.22.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:62d9cfcf4948683a18a9aff0ab7e1474d407b7bab2ca03116109f8464698ab16", size = 587062 }, + { url = "https://files.pythonhosted.org/packages/67/e0/2034c221937709bf9c542603d25ad43a68b4b0a9a0c0b06a742f2756eb66/rpds_py-0.22.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9253fc214112405f0afa7db88739294295f0e08466987f1d70e29930262b4c8f", size = 555734 }, + { url = "https://files.pythonhosted.org/packages/ea/ce/240bae07b5401a22482b58e18cfbabaa392409b2797da60223cca10d7367/rpds_py-0.22.3-cp313-cp313-win32.whl", hash = "sha256:fb0ba113b4983beac1a2eb16faffd76cb41e176bf58c4afe3e14b9c681f702de", size = 220663 }, + { url = "https://files.pythonhosted.org/packages/cb/f0/d330d08f51126330467edae2fa4efa5cec8923c87551a79299380fdea30d/rpds_py-0.22.3-cp313-cp313-win_amd64.whl", hash = "sha256:c58e2339def52ef6b71b8f36d13c3688ea23fa093353f3a4fee2556e62086ec9", size = 235503 }, + { url = "https://files.pythonhosted.org/packages/f7/c4/dbe1cc03df013bf2feb5ad00615038050e7859f381e96fb5b7b4572cd814/rpds_py-0.22.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f82a116a1d03628a8ace4859556fb39fd1424c933341a08ea3ed6de1edb0283b", size = 347698 }, + { url = "https://files.pythonhosted.org/packages/a4/3a/684f66dd6b0f37499cad24cd1c0e523541fd768576fa5ce2d0a8799c3cba/rpds_py-0.22.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3dfcbc95bd7992b16f3f7ba05af8a64ca694331bd24f9157b49dadeeb287493b", size = 337330 }, + { url = "https://files.pythonhosted.org/packages/82/eb/e022c08c2ce2e8f7683baa313476492c0e2c1ca97227fe8a75d9f0181e95/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59259dc58e57b10e7e18ce02c311804c10c5a793e6568f8af4dead03264584d1", size = 380022 }, + { url = "https://files.pythonhosted.org/packages/e4/21/5a80e653e4c86aeb28eb4fea4add1f72e1787a3299687a9187105c3ee966/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5725dd9cc02068996d4438d397e255dcb1df776b7ceea3b9cb972bdb11260a83", size = 390754 }, + { url = "https://files.pythonhosted.org/packages/37/a4/d320a04ae90f72d080b3d74597074e62be0a8ecad7d7321312dfe2dc5a6a/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99b37292234e61325e7a5bb9689e55e48c3f5f603af88b1642666277a81f1fbd", size = 423840 }, + { url = "https://files.pythonhosted.org/packages/87/70/674dc47d93db30a6624279284e5631be4c3a12a0340e8e4f349153546728/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27b1d3b3915a99208fee9ab092b8184c420f2905b7d7feb4aeb5e4a9c509b8a1", size = 438970 }, + { url = "https://files.pythonhosted.org/packages/3f/64/9500f4d66601d55cadd21e90784cfd5d5f4560e129d72e4339823129171c/rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f612463ac081803f243ff13cccc648578e2279295048f2a8d5eb430af2bae6e3", size = 383146 }, + { url = "https://files.pythonhosted.org/packages/4d/45/630327addb1d17173adcf4af01336fd0ee030c04798027dfcb50106001e0/rpds_py-0.22.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f73d3fef726b3243a811121de45193c0ca75f6407fe66f3f4e183c983573e130", size = 408294 }, + { url = "https://files.pythonhosted.org/packages/5f/ef/8efb3373cee54ea9d9980b772e5690a0c9e9214045a4e7fa35046e399fee/rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3f21f0495edea7fdbaaa87e633a8689cd285f8f4af5c869f27bc8074638ad69c", size = 556345 }, + { url = "https://files.pythonhosted.org/packages/54/01/151d3b9ef4925fc8f15bfb131086c12ec3c3d6dd4a4f7589c335bf8e85ba/rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1e9663daaf7a63ceccbbb8e3808fe90415b0757e2abddbfc2e06c857bf8c5e2b", size = 582292 }, + { url = "https://files.pythonhosted.org/packages/30/89/35fc7a6cdf3477d441c7aca5e9bbf5a14e0f25152aed7f63f4e0b141045d/rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a76e42402542b1fae59798fab64432b2d015ab9d0c8c47ba7addddbaf7952333", size = 553855 }, + { url = "https://files.pythonhosted.org/packages/8f/e0/830c02b2457c4bd20a8c5bb394d31d81f57fbefce2dbdd2e31feff4f7003/rpds_py-0.22.3-cp313-cp313t-win32.whl", hash = "sha256:69803198097467ee7282750acb507fba35ca22cc3b85f16cf45fb01cb9097730", size = 219100 }, + { url = "https://files.pythonhosted.org/packages/f8/30/7ac943f69855c2db77407ae363484b915d861702dbba1aa82d68d57f42be/rpds_py-0.22.3-cp313-cp313t-win_amd64.whl", hash = "sha256:f5cf2a0c2bdadf3791b5c205d55a37a54025c6e18a71c71f82bb536cf9a454bf", size = 233794 }, +] + +[[package]] +name = "send2trash" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3a/aec9b02217bb79b87bbc1a21bc6abc51e3d5dcf65c30487ac96c0908c722/Send2Trash-1.8.3.tar.gz", hash = "sha256:b18e7a3966d99871aefeb00cfbcfdced55ce4871194810fc71f4aa484b953abf", size = 17394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b0/4562db6223154aa4e22f939003cb92514c79f3d4dccca3444253fd17f902/Send2Trash-1.8.3-py3-none-any.whl", hash = "sha256:0c31227e0bd08961c7665474a3d1ef7193929fedda4233843689baa056be46c9", size = 18072 }, +] + +[[package]] +name = "setuptools" +version = "75.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/54/292f26c208734e9a7f067aea4a7e282c080750c4546559b58e2e45413ca0/setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", size = 1337429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "soupsieve" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/bf/005dc47f0e57556e14512d5542f3f183b94fde46e15ff1588ec58ca89555/SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", size = 2092378 }, + { url = "https://files.pythonhosted.org/packages/94/65/f109d5720779a08e6e324ec89a744f5f92c48bd8005edc814bf72fbb24e5/SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", size = 2082778 }, + { url = "https://files.pythonhosted.org/packages/60/f6/d9aa8c49c44f9b8c9b9dada1f12fa78df3d4c42aa2de437164b83ee1123c/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53", size = 3232191 }, + { url = "https://files.pythonhosted.org/packages/8a/ab/81d4514527c068670cb1d7ab62a81a185df53a7c379bd2a5636e83d09ede/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", size = 3243044 }, + { url = "https://files.pythonhosted.org/packages/35/b4/f87c014ecf5167dc669199cafdb20a7358ff4b1d49ce3622cc48571f811c/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", size = 3178511 }, + { url = "https://files.pythonhosted.org/packages/ea/09/badfc9293bc3ccba6ede05e5f2b44a760aa47d84da1fc5a326e963e3d4d9/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", size = 3205147 }, + { url = "https://files.pythonhosted.org/packages/c8/60/70e681de02a13c4b27979b7b78da3058c49bacc9858c89ba672e030f03f2/SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", size = 2062709 }, + { url = "https://files.pythonhosted.org/packages/b7/ed/f6cd9395e41bfe47dd253d74d2dfc3cab34980d4e20c8878cb1117306085/SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", size = 2088433 }, + { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651 }, + { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132 }, + { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559 }, + { url = "https://files.pythonhosted.org/packages/7b/c5/07f18a897b997f6d6b234fab2bf31dccf66d5d16a79fe329aefc95cd7461/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", size = 3177897 }, + { url = "https://files.pythonhosted.org/packages/b3/cd/e16f3cbefd82b5c40b33732da634ec67a5f33b587744c7ab41699789d492/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", size = 3111289 }, + { url = "https://files.pythonhosted.org/packages/15/85/5b8a3b0bc29c9928aa62b5c91fcc8335f57c1de0a6343873b5f372e3672b/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", size = 3139491 }, + { url = "https://files.pythonhosted.org/packages/a1/95/81babb6089938680dfe2cd3f88cd3fd39cccd1543b7cb603b21ad881bff1/SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", size = 2060439 }, + { url = "https://files.pythonhosted.org/packages/c1/ce/5f7428df55660d6879d0522adc73a3364970b5ef33ec17fa125c5dbcac1d/SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", size = 2084574 }, + { url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787 }, +] + +[[package]] +name = "sqlalchemy-schemadisplay" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pydot" }, + { name = "setuptools" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/b0/d4587a6223dd563072ed5d0b94e0d062bb3d019bf16d0e65a85324c49efc/sqlalchemy_schemadisplay-2.0.tar.gz", hash = "sha256:e90b9c9868814975d674a889aadb7c4651658f0e119e1c9320279ea527744d5e", size = 11637 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/9e/a8d5cea8fb842393846ad42596eeb989511fab0aa1c88fe226c24c6355e7/sqlalchemy_schemadisplay-2.0-py3-none-any.whl", hash = "sha256:e4b928e2aec145f72a2b35de7855a78fca5e09ac4d48f2d58b4472cb640cd362", size = 11377 }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/39/8641040ab0d5e1d8a1c2325ae89a01ae659fc96c61a43d158fb71c9a0bf0/sqlmodel-0.0.22.tar.gz", hash = "sha256:7d37c882a30c43464d143e35e9ecaf945d88035e20117bf5ec2834a23cbe505e", size = 116392 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "starlette" +version = "0.41.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154 }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, +] + +[[package]] +name = "tornado" +version = "6.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299 }, + { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253 }, + { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972 }, + { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173 }, + { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892 }, + { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334 }, + { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261 }, + { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463 }, + { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241206" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/60/47d92293d9bc521cd2301e423a358abfac0ad409b3a1606d8fbae1321961/types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb", size = 13802 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/b3/ca41df24db5eb99b00d97f89d7674a90cb6b3134c52fb8121b6d8d30f15c/types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53", size = 14384 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140 }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "webcolors" +version = "24.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/29/061ec845fb58521848f3739e466efd8250b4b7b98c1b6c5bf4d40b419b7e/webcolors-24.11.1.tar.gz", hash = "sha256:ecb3d768f32202af770477b8b65f318fa4f566c22948673a977b00d589dd80f6", size = 45064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e8/c0e05e4684d13459f93d312077a9a2efbe04d59c393bc2b8802248c908d4/webcolors-24.11.1-py3-none-any.whl", hash = "sha256:515291393b4cdf0eb19c155749a096f779f7d909f7cceea072791cb9095b92e9", size = 14934 }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/fc/238c424fd7f4ebb25f8b1da9a934a3ad7c848286732ae04263661eb0fc03/widgetsnbextension-4.0.13.tar.gz", hash = "sha256:ffcb67bc9febd10234a362795f643927f4e0c05d9342c727b65d2384f8feacb6", size = 1164730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/02/88b65cc394961a60c43c70517066b6b679738caf78506a5da7b88ffcb643/widgetsnbextension-4.0.13-py3-none-any.whl", hash = "sha256:74b2692e8500525cc38c2b877236ba51d34541e6385eeed5aec15a70f88a6c71", size = 2335872 }, +] From f947dc98db2da32b858bf2bab306a1c531fa337f Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sun, 15 Dec 2024 16:24:29 -0500 Subject: [PATCH 68/73] Remove URL upload of avatar Only get avatar if user is authorized --- routers/user.py | 72 +++++++++++++------------------ templates/users/organization.html | 6 +-- templates/users/profile.html | 54 ++--------------------- tests/test_user.py | 71 +++++++++++++++++++++++++++--- utils/models.py | 1 - 5 files changed, 100 insertions(+), 104 deletions(-) diff --git a/routers/user.py b/routers/user.py index 8c3a247..e7c763f 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,10 +1,10 @@ -from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File +from fastapi import APIRouter, Depends, Form, UploadFile, File from fastapi.responses import RedirectResponse, Response from pydantic import BaseModel, EmailStr -from sqlmodel import Session, select +from sqlmodel import Session from typing import Optional -from utils.models import User -from utils.auth import get_session, get_authenticated_user, verify_password +from utils.models import User, DataIntegrityError +from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError router = APIRouter(prefix="/user", tags=["user"]) @@ -16,7 +16,6 @@ class UpdateProfile(BaseModel): """Request model for updating user profile information""" name: str email: EmailStr - avatar_url: Optional[str] = None avatar_file: Optional[bytes] = None avatar_content_type: Optional[str] = None @@ -25,21 +24,18 @@ async def as_form( cls, name: str = Form(...), email: EmailStr = Form(...), - avatar_url: Optional[str] = Form(None), avatar_file: Optional[UploadFile] = File(None), ): avatar_data = None avatar_content_type = None if avatar_file: - # Read the file content avatar_data = await avatar_file.read() avatar_content_type = avatar_file.content_type return cls( name=name, email=email, - avatar_url=avatar_url if not avatar_file else None, avatar_file=avatar_data, avatar_content_type=avatar_content_type ) @@ -62,25 +58,20 @@ async def as_form( @router.post("/update_profile", response_class=RedirectResponse) async def update_profile( user_profile: UpdateProfile = Depends(UpdateProfile.as_form), - current_user: User = Depends(get_authenticated_user), + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Update user details - current_user.name = user_profile.name - current_user.email = user_profile.email + user.name = user_profile.name + user.email = user_profile.email # Handle avatar update if user_profile.avatar_file: - current_user.avatar_url = None - current_user.avatar_data = user_profile.avatar_file - current_user.avatar_content_type = user_profile.avatar_content_type - else: - current_user.avatar_url = user_profile.avatar_url - current_user.avatar_data = None - current_user.avatar_content_type = None + user.avatar_data = user_profile.avatar_file + user.avatar_content_type = user_profile.avatar_content_type session.commit() - session.refresh(current_user) + session.refresh(user) return RedirectResponse(url="/profile", status_code=303) @@ -88,48 +79,43 @@ async def update_profile( async def delete_account( user_delete_account: UserDeleteAccount = Depends( UserDeleteAccount.as_form), - current_user: User = Depends(get_authenticated_user), + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): - if not current_user.password: - raise HTTPException( - status_code=500, - detail="User password not found in database; please contact a system administrator" + if not user.password: + raise DataIntegrityError( + resource="User password" ) if not verify_password( user_delete_account.confirm_delete_password, - current_user.password.hashed_password + user.password.hashed_password ): - raise HTTPException( - status_code=400, - detail="Password is incorrect" + raise PasswordValidationError( + field="confirm_delete_password", + message="Password is incorrect" ) # Delete the user - session.delete(current_user) + session.delete(user) session.commit() # Log out the user return RedirectResponse(url="/auth/logout", status_code=303) -@router.get("/avatar/{user_id}") +@router.get("/avatar") async def get_avatar( - user_id: int, + user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): """Serve avatar image from database""" - user = session.exec(select(User).where(User.id == user_id)).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - if user.avatar_data: - return Response( - content=user.avatar_data, - media_type=user.avatar_content_type + if not user.avatar_data: + raise DataIntegrityError( + resource="User avatar" ) - elif user.avatar_url: - return RedirectResponse(url=user.avatar_url) - else: - raise HTTPException(status_code=404, detail="Avatar not found") + + return Response( + content=user.avatar_data, + media_type=user.avatar_content_type + ) diff --git a/templates/users/organization.html b/templates/users/organization.html index 2e75fa9..913598b 100644 --- a/templates/users/organization.html +++ b/templates/users/organization.html @@ -63,11 +63,7 @@

{{ organization.name }}

{% for user in role.users %} - {% if user.avatar_url %} - User Avatar - {% else %} - {{ render_silhouette(width=40, height=40) }} - {% endif %} + {{ render_silhouette(width=40, height=40) }} {{ user.name }} {{ user.email }} diff --git a/templates/users/profile.html b/templates/users/profile.html index b51d582..fd4ac0b 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -18,8 +18,8 @@

User Profile

Email: {{ user.email }}

- {% if user.avatar_url or user.avatar_data %} - User Avatar + {% if user.avatar_data %} + User Avatar {% else %} {{ render_silhouette(width=150, height=150) }} {% endif %} @@ -45,20 +45,8 @@

User Profile

- -
- - -
-
- -
- + +
@@ -115,39 +103,5 @@

User Profile

editProfile.style.display = 'block'; } } - - // Function to toggle between URL and file upload inputs - function toggleAvatarInput() { - var avatarType = document.getElementById('avatar_type').value; - var urlInput = document.getElementById('url_input'); - var fileInput = document.getElementById('file_input'); - var urlField = document.getElementById('avatar_url'); - - if (avatarType === 'url') { - urlInput.style.display = 'block'; - fileInput.style.display = 'none'; - // Clear file input - document.getElementById('avatar_file').value = ''; - } else { - urlInput.style.display = 'none'; - fileInput.style.display = 'block'; - // Clear URL input - urlField.value = ''; - } - } - - // Add form submission validation - document.querySelector('form[action="{{ url_for("update_profile") }}"]').addEventListener('submit', function(e) { - var avatarType = document.getElementById('avatar_type').value; - var urlField = document.getElementById('avatar_url'); - var fileField = document.getElementById('avatar_file'); - - // Clear the unused field before submission - if (avatarType === 'url') { - fileField.value = ''; - } else { - urlField.value = ''; - } - }); {% endblock %} diff --git a/tests/test_user.py b/tests/test_user.py index d6f42d0..674a1c1 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -13,7 +13,9 @@ def test_update_profile_unauthorized(unauth_client: TestClient): data={ "name": "New Name", "email": "new@example.com", - "avatar_url": "https://example.com/avatar.jpg" + }, + files={ + "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") }, follow_redirects=False ) @@ -23,14 +25,40 @@ def test_update_profile_unauthorized(unauth_client: TestClient): def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session): """Test that authorized users can edit their profile""" - + + # Create test image data + test_image_data = b"fake image data" + # Update profile response: Response = auth_client.post( app.url_path_for("update_profile"), data={ "name": "Updated Name", "email": "updated@example.com", - "avatar_url": "https://example.com/new-avatar.jpg" + }, + files={ + "avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg") + }, + follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == app.url_path_for("read_profile") + + # Verify changes in database + session.refresh(test_user) + assert test_user.name == "Updated Name" + assert test_user.email == "updated@example.com" + assert test_user.avatar_data == test_image_data + assert test_user.avatar_content_type == "image/jpeg" + + +def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session): + """Test that profile can be updated without changing the avatar""" + response: Response = auth_client.post( + app.url_path_for("update_profile"), + data={ + "name": "Updated Name", + "email": "updated@example.com", }, follow_redirects=False ) @@ -41,7 +69,6 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses session.refresh(test_user) assert test_user.name == "Updated Name" assert test_user.email == "updated@example.com" - assert test_user.avatar_url == "https://example.com/new-avatar.jpg" def test_delete_account_unauthorized(unauth_client: TestClient): @@ -62,7 +89,7 @@ def test_delete_account_wrong_password(auth_client: TestClient, test_user: User) data={"confirm_delete_password": "WrongPassword123!"}, follow_redirects=False ) - assert response.status_code == 400 + assert response.status_code == 422 assert "Password is incorrect" in response.text.strip() @@ -81,3 +108,37 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio # Verify user is deleted from database user = session.get(User, test_user.id) assert user is None + + +def test_get_avatar_authorized(auth_client: TestClient, test_user: User): + """Test getting user avatar""" + # First upload an avatar + test_image_data = b"fake image data" + auth_client.post( + app.url_path_for("update_profile"), + data={ + "name": test_user.name, + "email": test_user.email, + }, + files={ + "avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg") + }, + ) + + # Then try to retrieve it + response = auth_client.get( + app.url_path_for("get_avatar") + ) + assert response.status_code == 200 + assert response.content == test_image_data + assert response.headers["content-type"] == "image/jpeg" + + +def test_get_avatar_unauthorized(unauth_client: TestClient): + """Test getting avatar for non-existent user""" + response = unauth_client.get( + app.url_path_for("get_avatar"), + follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == app.url_path_for("read_login") diff --git a/utils/models.py b/utils/models.py index ef53aef..29e468e 100644 --- a/utils/models.py +++ b/utils/models.py @@ -180,7 +180,6 @@ class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str email: str = Field(index=True, unique=True) - avatar_url: Optional[str] = None avatar_data: Optional[bytes] = Field( default=None, sa_column=Column(LargeBinary)) avatar_content_type: Optional[str] = None From deed7a3c279cef363e6e473af5eb57e5e132f9d0 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sun, 15 Dec 2024 16:40:25 -0500 Subject: [PATCH 69/73] Updated data model documentation --- docs/architecture.qmd | 17 +++++------------ docs/static/documentation.txt | 17 +++++------------ docs/static/schema.png | Bin 49687 -> 67198 bytes 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/docs/architecture.qmd b/docs/architecture.qmd index 7dfd136..1079e53 100644 --- a/docs/architecture.qmd +++ b/docs/architecture.qmd @@ -102,18 +102,16 @@ Here are some patterns we've considered for server-side error handling: border-collapse: collapse; } -.styled-table th:nth-child(1) { width: 5%; } -.styled-table th:nth-child(2) { width: 50%; } -.styled-table th:nth-child(3), -.styled-table th:nth-child(4), -.styled-table th:nth-child(5) { width: 15%; } -.styled-table th:nth-child(6) { width: 10%; } +.styled-table th:nth-child(1) { width: 50%; } +.styled-table th:nth-child(2), +.styled-table th:nth-child(3), +.styled-table th:nth-child(4) { width: 15%; } +.styled-table th:nth-child(5) { width: 10%; } - @@ -123,7 +121,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -131,7 +128,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -139,7 +135,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -147,7 +142,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -155,7 +149,6 @@ Here are some patterns we've considered for server-side error handling: - diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index f15677b..15713bb 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -322,18 +322,16 @@ Here are some patterns we've considered for server-side error handling: border-collapse: collapse; } -.styled-table th:nth-child(1) { width: 5%; } -.styled-table th:nth-child(2) { width: 50%; } -.styled-table th:nth-child(3), -.styled-table th:nth-child(4), -.styled-table th:nth-child(5) { width: 15%; } -.styled-table th:nth-child(6) { width: 10%; } +.styled-table th:nth-child(1) { width: 50%; } +.styled-table th:nth-child(2), +.styled-table th:nth-child(3), +.styled-table th:nth-child(4) { width: 15%; } +.styled-table th:nth-child(5) { width: 10%; }
ID Approach Returns to same page Preserves form inputs
1 Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button No YesLow
2 Validate in FastAPI endpoint function body, redirect to origin page with error message query param Yes NoMedium
3 Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputs Yes YesHigh
4 Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputs Yes YesHigh
5 Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level Javascript Yes Yes
- @@ -343,7 +341,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -351,7 +348,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -359,7 +355,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -367,7 +362,6 @@ Here are some patterns we've considered for server-side error handling: - @@ -375,7 +369,6 @@ Here are some patterns we've considered for server-side error handling: - diff --git a/docs/static/schema.png b/docs/static/schema.png index b47a3a19535b28c750a848c990cc1b58a4b64efe..49cacfeb7a5072d44fda6fa15253fae424e3d84e 100644 GIT binary patch literal 67198 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ξ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 literal 49687 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 b`q}W2hq`x3gC`aW{uyd9(mc*|+PeP-t{yEl From a22d44c483e7b2e7286206b254df09a384d91f5f Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sun, 15 Dec 2024 17:15:16 -0500 Subject: [PATCH 70/73] First pass at user profile image validation --- pyproject.toml | 1 + routers/user.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++-- uv.lock | 2 ++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1865278..d91deab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "resend<3.0.0,>=2.4.0", "bcrypt<5.0.0,>=4.2.0", "fastapi<1.0.0,>=0.115.5", + "pillow>=11.0.0", ] [dependency-groups] diff --git a/routers/user.py b/routers/user.py index 504d8d1..e8348fb 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,14 +1,41 @@ -from fastapi import APIRouter, Depends, Form, UploadFile, File +from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException from fastapi.responses import RedirectResponse, Response from pydantic import BaseModel, EmailStr from sqlmodel import Session from typing import Optional from utils.models import User, DataIntegrityError from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError +from PIL import Image +import io router = APIRouter(prefix="/user", tags=["user"]) +# --- Constants --- + + +# 2MB in bytes +MAX_FILE_SIZE = 2 * 1024 * 1024 +ALLOWED_CONTENT_TYPES = { + 'image/jpeg', + 'image/png', + 'image/webp' +} +MIN_DIMENSION = 100 +MAX_DIMENSION = 2000 +TARGET_SIZE = 500 + + +# --- Custom Exceptions --- + + +class InvalidImageError(HTTPException): + """Raised when an invalid image is uploaded""" + + def __init__(self, message: str = "Invalid image file"): + super().__init__(status_code=400, detail=message) + + # --- Server Request and Response Models --- @@ -61,11 +88,62 @@ async def update_profile( user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): + # Handle avatar update + if user_profile.avatar_file: + # Check file size + if len(user_profile.avatar_file) > MAX_FILE_SIZE: + raise InvalidImageError( + message="File too large (max 2MB)" + ) + + # Check file type + if user_profile.avatar_content_type not in ALLOWED_CONTENT_TYPES: + raise InvalidImageError( + message="Invalid file type. Must be JPEG, PNG, or WebP" + ) + + try: + # Open and validate image + image = Image.open(io.BytesIO(user_profile.avatar_file)) + width, height = image.size + + # Check minimum dimensions + if width < MIN_DIMENSION or height < MIN_DIMENSION: + raise InvalidImageError( + message=f"Image too small. Minimum dimension is {MIN_DIMENSION}px" + ) + + # Check maximum dimensions + if width > MAX_DIMENSION or height > MAX_DIMENSION: + raise InvalidImageError( + message=f"Image too large. Maximum dimension is {MAX_DIMENSION}px" + ) + + # Crop to square and resize + min_dim = min(width, height) + left = (width - min_dim) // 2 + top = (height - min_dim) // 2 + right = left + min_dim + bottom = top + min_dim + + image = image.crop((left, top, right, bottom)) + image = image.resize((TARGET_SIZE, TARGET_SIZE), Image.Resampling.LANCZOS) + + # Convert back to bytes + output = io.BytesIO() + image.save(output, format='PNG') + user_profile.avatar_file = output.getvalue() + user_profile.avatar_content_type = 'image/png' + + except Exception as e: + raise InvalidImageError( + message="Invalid image file" + ) + # Update user details user.name = user_profile.name user.email = user_profile.email - - # Handle avatar update + if user_profile.avatar_file: user.avatar_data = user_profile.avatar_file user.avatar_content_type = user_profile.avatar_content_type diff --git a/uv.lock b/uv.lock index 9d54f78..e749a85 100644 --- a/uv.lock +++ b/uv.lock @@ -371,6 +371,7 @@ dependencies = [ { name = "bcrypt" }, { name = "fastapi" }, { name = "jinja2" }, + { name = "pillow" }, { name = "psycopg2" }, { name = "pydantic", extra = ["email"] }, { name = "pyjwt" }, @@ -397,6 +398,7 @@ requires-dist = [ { name = "bcrypt", specifier = ">=4.2.0,<5.0.0" }, { name = "fastapi", specifier = ">=0.115.5,<1.0.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, + { name = "pillow", specifier = ">=11.0.0" }, { name = "psycopg2", specifier = ">=2.9.10,<3.0.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.9.2,<3.0.0" }, { name = "pyjwt", specifier = ">=2.10.1,<3.0.0" }, From b453116757a214779d7f6b23d12365d0b0b0024c Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Sun, 15 Dec 2024 21:12:25 -0500 Subject: [PATCH 71/73] Added server-side image validation and tests --- docs/installation.qmd | 4 +- index.qmd | 2 +- routers/user.py | 85 ++++------------------------------ tests/test_images.py | 104 ++++++++++++++++++++++++++++++++++++++++++ tests/test_user.py | 56 ++++++++++++++++++----- utils/images.py | 87 +++++++++++++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 91 deletions(-) create mode 100644 tests/test_images.py create mode 100644 utils/images.py diff --git a/docs/installation.qmd b/docs/installation.qmd index e85c13b..ef3fe37 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -10,7 +10,7 @@ If you use VSCode with Docker to develop in a container, the following VSCode De { "name": "Python 3", "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && uv venv && uv sync", "features": { "ghcr.io/va-h/devcontainers-features/uv:1": { "version": "latest" @@ -61,7 +61,7 @@ Install Docker Desktop and Docker Compose for your operating system by following For Ubuntu/Debian: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev ``` For macOS: diff --git a/index.qmd b/index.qmd index 2b63ad7..6b51fb7 100644 --- a/index.qmd +++ b/index.qmd @@ -84,7 +84,7 @@ Install Docker Desktop and Coker Compose for your operating system by following For Ubuntu/Debian: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev ``` For macOS: diff --git a/routers/user.py b/routers/user.py index e8348fb..1baf82a 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,41 +1,15 @@ -from fastapi import APIRouter, Depends, Form, UploadFile, File, HTTPException +from fastapi import APIRouter, Depends, Form, UploadFile, File from fastapi.responses import RedirectResponse, Response from pydantic import BaseModel, EmailStr from sqlmodel import Session from typing import Optional from utils.models import User, DataIntegrityError from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError -from PIL import Image -import io +from utils.images import validate_and_process_image router = APIRouter(prefix="/user", tags=["user"]) -# --- Constants --- - - -# 2MB in bytes -MAX_FILE_SIZE = 2 * 1024 * 1024 -ALLOWED_CONTENT_TYPES = { - 'image/jpeg', - 'image/png', - 'image/webp' -} -MIN_DIMENSION = 100 -MAX_DIMENSION = 2000 -TARGET_SIZE = 500 - - -# --- Custom Exceptions --- - - -class InvalidImageError(HTTPException): - """Raised when an invalid image is uploaded""" - - def __init__(self, message: str = "Invalid image file"): - super().__init__(status_code=400, detail=message) - - # --- Server Request and Response Models --- @@ -90,55 +64,12 @@ async def update_profile( ): # Handle avatar update if user_profile.avatar_file: - # Check file size - if len(user_profile.avatar_file) > MAX_FILE_SIZE: - raise InvalidImageError( - message="File too large (max 2MB)" - ) - - # Check file type - if user_profile.avatar_content_type not in ALLOWED_CONTENT_TYPES: - raise InvalidImageError( - message="Invalid file type. Must be JPEG, PNG, or WebP" - ) - - try: - # Open and validate image - image = Image.open(io.BytesIO(user_profile.avatar_file)) - width, height = image.size - - # Check minimum dimensions - if width < MIN_DIMENSION or height < MIN_DIMENSION: - raise InvalidImageError( - message=f"Image too small. Minimum dimension is {MIN_DIMENSION}px" - ) - - # Check maximum dimensions - if width > MAX_DIMENSION or height > MAX_DIMENSION: - raise InvalidImageError( - message=f"Image too large. Maximum dimension is {MAX_DIMENSION}px" - ) - - # Crop to square and resize - min_dim = min(width, height) - left = (width - min_dim) // 2 - top = (height - min_dim) // 2 - right = left + min_dim - bottom = top + min_dim - - image = image.crop((left, top, right, bottom)) - image = image.resize((TARGET_SIZE, TARGET_SIZE), Image.Resampling.LANCZOS) - - # Convert back to bytes - output = io.BytesIO() - image.save(output, format='PNG') - user_profile.avatar_file = output.getvalue() - user_profile.avatar_content_type = 'image/png' - - except Exception as e: - raise InvalidImageError( - message="Invalid image file" - ) + processed_image, content_type = validate_and_process_image( + user_profile.avatar_file, + user_profile.avatar_content_type + ) + user_profile.avatar_file = processed_image + user_profile.avatar_content_type = content_type # Update user details user.name = user_profile.name diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..23d3521 --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,104 @@ +import pytest +from PIL import Image +import io +from utils.images import ( + validate_and_process_image, + InvalidImageError, + MAX_FILE_SIZE, + MIN_DIMENSION, + MAX_DIMENSION, + TARGET_SIZE +) + +def create_test_image(width: int, height: int, format: str = 'PNG') -> bytes: + """Helper function to create test images""" + image = Image.new('RGB', (width, height), color='red') + output = io.BytesIO() + image.save(output, format=format) + return output.getvalue() + +def test_webp_dependencies_are_installed(): + """Test that webp dependencies are installed""" + assert '.webp' in Image.registered_extensions(), "WebP dependencies are not installed (e.g., libwebp-dev on Linux)" + +def test_valid_square_image(): + """Test processing a valid square image""" + image_data = create_test_image(500, 500) + processed_data, content_type = validate_and_process_image(image_data, 'image/png') + + # Verify the processed image + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + assert content_type == 'image/png' + +def test_valid_rectangular_image(): + """Test processing a valid rectangular image""" + image_data = create_test_image(800, 600) + processed_data, content_type = validate_and_process_image(image_data, 'image/png') + + # Verify the processed image + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + assert content_type == 'image/png' + +def test_minimum_size_image(): + """Test processing an image with minimum allowed dimensions""" + image_data = create_test_image(MIN_DIMENSION, MIN_DIMENSION) + processed_data, content_type = validate_and_process_image(image_data, 'image/png') + + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + +def test_too_small_image(): + """Test that too small images are rejected""" + image_data = create_test_image(MIN_DIMENSION - 1, MIN_DIMENSION - 1) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(image_data, 'image/png') + assert "Image too small" in str(exc_info.value.detail) + +def test_too_large_image(): + """Test that too large images are rejected""" + image_data = create_test_image(MAX_DIMENSION + 1, MAX_DIMENSION + 1) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(image_data, 'image/png') + assert "Image too large" in str(exc_info.value.detail) + +def test_invalid_file_type(): + """Test that invalid file types are rejected""" + image_data = create_test_image(500, 500) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(image_data, 'image/gif') + assert "Invalid file type" in str(exc_info.value.detail) + +def test_file_too_large(): + """Test that files exceeding MAX_FILE_SIZE are rejected""" + # Create a large file that exceeds MAX_FILE_SIZE + large_image_data = b'0' * (MAX_FILE_SIZE + 1) + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(large_image_data, 'image/png') + assert "File too large" in str(exc_info.value.detail) + +def test_corrupt_image_data(): + """Test that corrupt image data is rejected""" + corrupt_data = b'not an image' + with pytest.raises(InvalidImageError) as exc_info: + validate_and_process_image(corrupt_data, 'image/png') + assert "Invalid image file" in str(exc_info.value.detail) + +def test_different_image_formats(): + """Test processing different valid image formats""" + formats = [ + ('JPEG', 'image/jpeg'), + ('PNG', 'image/png'), + ('WEBP', 'image/webp') + ] + + for format_name, content_type in formats: + image_data = create_test_image(500, 500, format_name) + processed_data, result_type = validate_and_process_image(image_data, content_type) + + # Verify the processed image + processed_image = Image.open(io.BytesIO(processed_data)) + assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + # Output should match input format + assert result_type == content_type diff --git a/tests/test_user.py b/tests/test_user.py index 674a1c1..47fb9fe 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,9 +1,15 @@ from fastapi.testclient import TestClient from httpx import Response from sqlmodel import Session +from unittest.mock import patch from main import app from utils.models import User +from utils.images import InvalidImageError + +# Mock data for consistent testing +MOCK_IMAGE_DATA = b"processed fake image data" +MOCK_CONTENT_TYPE = "image/png" def test_update_profile_unauthorized(unauth_client: TestClient): @@ -23,11 +29,12 @@ def test_update_profile_unauthorized(unauth_client: TestClient): assert response.headers["location"] == app.url_path_for("read_login") -def test_update_profile_authorized(auth_client: TestClient, test_user: User, session: Session): +@patch('routers.user.validate_and_process_image') +def test_update_profile_authorized(mock_validate, auth_client: TestClient, test_user: User, session: Session): """Test that authorized users can edit their profile""" - # Create test image data - test_image_data = b"fake image data" + # Configure mock to return processed image data + mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE) # Update profile response: Response = auth_client.post( @@ -37,7 +44,7 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses "email": "updated@example.com", }, files={ - "avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg") + "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") }, follow_redirects=False ) @@ -48,8 +55,11 @@ def test_update_profile_authorized(auth_client: TestClient, test_user: User, ses session.refresh(test_user) assert test_user.name == "Updated Name" assert test_user.email == "updated@example.com" - assert test_user.avatar_data == test_image_data - assert test_user.avatar_content_type == "image/jpeg" + assert test_user.avatar_data == MOCK_IMAGE_DATA + assert test_user.avatar_content_type == MOCK_CONTENT_TYPE + + # Verify mock was called correctly + mock_validate.assert_called_once() def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session): @@ -110,10 +120,13 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio assert user is None -def test_get_avatar_authorized(auth_client: TestClient, test_user: User): +@patch('routers.user.validate_and_process_image') +def test_get_avatar_authorized(mock_validate, auth_client: TestClient, test_user: User): """Test getting user avatar""" + # Configure mock to return processed image data + mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE) + # First upload an avatar - test_image_data = b"fake image data" auth_client.post( app.url_path_for("update_profile"), data={ @@ -121,7 +134,7 @@ def test_get_avatar_authorized(auth_client: TestClient, test_user: User): "email": test_user.email, }, files={ - "avatar_file": ("test_avatar.jpg", test_image_data, "image/jpeg") + "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") }, ) @@ -130,8 +143,8 @@ def test_get_avatar_authorized(auth_client: TestClient, test_user: User): app.url_path_for("get_avatar") ) assert response.status_code == 200 - assert response.content == test_image_data - assert response.headers["content-type"] == "image/jpeg" + assert response.content == MOCK_IMAGE_DATA + assert response.headers["content-type"] == MOCK_CONTENT_TYPE def test_get_avatar_unauthorized(unauth_client: TestClient): @@ -142,3 +155,24 @@ def test_get_avatar_unauthorized(unauth_client: TestClient): ) assert response.status_code == 303 assert response.headers["location"] == app.url_path_for("read_login") + + +# Add new test for invalid image +@patch('routers.user.validate_and_process_image') +def test_update_profile_invalid_image(mock_validate, auth_client: TestClient): + """Test that invalid images are rejected""" + # Configure mock to raise InvalidImageError + mock_validate.side_effect = InvalidImageError("Invalid test image") + + response: Response = auth_client.post( + app.url_path_for("update_profile"), + data={ + "name": "Updated Name", + "email": "updated@example.com", + }, + files={ + "avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg") + }, + ) + assert response.status_code == 400 + assert "Invalid test image" in response.text diff --git a/utils/images.py b/utils/images.py new file mode 100644 index 0000000..8eb6540 --- /dev/null +++ b/utils/images.py @@ -0,0 +1,87 @@ +# utils/images.py +from fastapi import HTTPException +from PIL import Image +import io +from typing import Tuple + +# Constants +MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB in bytes +ALLOWED_CONTENT_TYPES = { + 'image/jpeg': 'JPEG', + 'image/png': 'PNG', + 'image/webp': 'WEBP' +} +MIN_DIMENSION = 100 +MAX_DIMENSION = 2000 +TARGET_SIZE = 500 + + +class InvalidImageError(HTTPException): + """Raised when an invalid image is uploaded""" + + def __init__(self, message: str = "Invalid image file"): + super().__init__(status_code=400, detail=message) + + +def validate_and_process_image( + image_data: bytes, + content_type: str | None +) -> Tuple[bytes, str]: + """ + Validates and processes an image file. + Returns a tuple of (processed_image_data, content_type). + + Raises: + InvalidImageError: If the image is invalid or doesn't meet requirements + """ + # Check file size + if len(image_data) > MAX_FILE_SIZE: + raise InvalidImageError( + message="File too large (max 2MB)" + ) + + # Check file type + if not content_type or content_type not in ALLOWED_CONTENT_TYPES: + raise InvalidImageError( + message="Invalid file type. Must be JPEG, PNG, or WebP" + ) + + try: + # Open and validate image + image: Image.Image = Image.open(io.BytesIO(image_data)) + width, height = image.size + except Exception as e: + raise InvalidImageError( + message="Invalid image file" + ) + + # Check minimum dimensions + if width < MIN_DIMENSION or height < MIN_DIMENSION: + raise InvalidImageError( + message=f"Image too small. Minimum dimension is {MIN_DIMENSION}px" + ) + + # Check maximum dimensions + if width > MAX_DIMENSION or height > MAX_DIMENSION: + raise InvalidImageError( + message=f"Image too large. Maximum dimension is {MAX_DIMENSION}px" + ) + + # Crop to square and resize + min_dim = min(width, height) + left = (width - min_dim) // 2 + top = (height - min_dim) // 2 + right = left + min_dim + bottom = top + min_dim + + image = image.crop((left, top, right, bottom)) + image = image.resize((TARGET_SIZE, TARGET_SIZE), Image.Resampling.LANCZOS) + + # Get the format from the content type + output_format = ALLOWED_CONTENT_TYPES[content_type] + + # Convert back to bytes + output = io.BytesIO() + image.save(output, format=output_format) + output.seek(0) + return output.getvalue(), content_type From 96ecbcda4732612ce099be8fd331a088e5273d3c Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 16 Dec 2024 09:35:38 -0500 Subject: [PATCH 72/73] Display info about image constraints on frontend --- docs/installation.qmd | 4 +++- main.py | 8 ++++++++ templates/authentication/register.html | 2 +- templates/users/profile.html | 9 +++++++++ tests/test_images.py | 11 +++++------ utils/images.py | 5 ++--- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/installation.qmd b/docs/installation.qmd index ef3fe37..378a9ae 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -165,7 +165,9 @@ Before running the development server, make sure the development database is run uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` -Navigate to http://localhost:8000/ +Navigate to http://localhost:8000/. + +(Note: If startup fails with a sqlalchemy/psycopg2 connection error, make sure that Docker Desktop and the database service are running and that the environment variables in the `.env` file are correctly populated, and then try again.) ## Lint types with mypy diff --git a/main.py b/main.py index 9ca359e..9ec7448 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from utils.auth import get_user_with_relations, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError from utils.models import User, Organization from utils.db import get_session, set_up_db +from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES logger = logging.getLogger("uvicorn.error") logger.setLevel(logging.DEBUG) @@ -246,6 +247,13 @@ async def read_dashboard( async def read_profile( params: dict = Depends(common_authenticated_parameters) ): + # Add image constraints to the template context + params.update({ + "max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB + "min_dimension": MIN_DIMENSION, + "max_dimension": MAX_DIMENSION, + "allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()) + }) return templates.TemplateResponse(params["request"], "users/profile.html", params) diff --git a/templates/authentication/register.html b/templates/authentication/register.html index e508fd1..ea84753 100644 --- a/templates/authentication/register.html +++ b/templates/authentication/register.html @@ -24,7 +24,7 @@
- + User Profile
+
+
    +
  • Maximum file size: {{ max_file_size_mb }} MB
  • +
  • Minimum dimension: {{ min_dimension }}x{{ min_dimension }} pixels
  • +
  • Maximum dimension: {{ max_dimension }}x{{ max_dimension }} pixels
  • +
  • Allowed formats: {{ allowed_formats|join(', ') }}
  • +
  • Image will be cropped to a square
  • +
+
diff --git a/tests/test_images.py b/tests/test_images.py index 23d3521..e4e80d2 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -6,8 +6,7 @@ InvalidImageError, MAX_FILE_SIZE, MIN_DIMENSION, - MAX_DIMENSION, - TARGET_SIZE + MAX_DIMENSION ) def create_test_image(width: int, height: int, format: str = 'PNG') -> bytes: @@ -28,7 +27,7 @@ def test_valid_square_image(): # Verify the processed image processed_image = Image.open(io.BytesIO(processed_data)) - assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + assert processed_image.size == (500, 500) assert content_type == 'image/png' def test_valid_rectangular_image(): @@ -38,7 +37,7 @@ def test_valid_rectangular_image(): # Verify the processed image processed_image = Image.open(io.BytesIO(processed_data)) - assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + assert processed_image.size == (600, 600) assert content_type == 'image/png' def test_minimum_size_image(): @@ -47,7 +46,7 @@ def test_minimum_size_image(): processed_data, content_type = validate_and_process_image(image_data, 'image/png') processed_image = Image.open(io.BytesIO(processed_data)) - assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + assert processed_image.size == (100, 100) def test_too_small_image(): """Test that too small images are rejected""" @@ -99,6 +98,6 @@ def test_different_image_formats(): # Verify the processed image processed_image = Image.open(io.BytesIO(processed_data)) - assert processed_image.size == (TARGET_SIZE, TARGET_SIZE) + assert processed_image.size == (500, 500) # Output should match input format assert result_type == content_type diff --git a/utils/images.py b/utils/images.py index 8eb6540..67bfaf4 100644 --- a/utils/images.py +++ b/utils/images.py @@ -13,7 +13,6 @@ } MIN_DIMENSION = 100 MAX_DIMENSION = 2000 -TARGET_SIZE = 500 class InvalidImageError(HTTPException): @@ -30,6 +29,7 @@ def validate_and_process_image( """ Validates and processes an image file. Returns a tuple of (processed_image_data, content_type). + Ensures the image is square by center-cropping. Raises: InvalidImageError: If the image is invalid or doesn't meet requirements @@ -67,7 +67,7 @@ def validate_and_process_image( message=f"Image too large. Maximum dimension is {MAX_DIMENSION}px" ) - # Crop to square and resize + # Crop to square min_dim = min(width, height) left = (width - min_dim) // 2 top = (height - min_dim) // 2 @@ -75,7 +75,6 @@ def validate_and_process_image( bottom = top + min_dim image = image.crop((left, top, right, bottom)) - image = image.resize((TARGET_SIZE, TARGET_SIZE), Image.Resampling.LANCZOS) # Get the format from the content type output_format = ALLOWED_CONTENT_TYPES[content_type] From 8173db253e64474adb3ef2c5db8e53ccdce8b5ee Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 16 Dec 2024 10:10:04 -0500 Subject: [PATCH 73/73] Client-side validation of uploaded profile image --- templates/users/profile.html | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/templates/users/profile.html b/templates/users/profile.html index fe7e9ba..c101db5 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -112,5 +112,53 @@

User Profile

editProfile.style.display = 'block'; } } + + document.getElementById('avatar_file').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + // Get constraints from your template variables + const maxSizeMB = "{{ max_file_size_mb }}"; + const minDimension = "{{ min_dimension }}"; + const maxDimension = "{{ max_dimension }}"; + const allowedFormats = "{{ allowed_formats }}"; + + + // Check file size + const fileSizeMB = file.size / (1024 * 1024); + if (fileSizeMB > maxSizeMB) { + alert(`File size must be less than ${maxSizeMB}MB`); + this.value = ''; + return; + } + + // Check file format + const fileFormat = file.type.split('/')[1]; + if (!allowedFormats.includes(fileFormat)) { + alert(`File format must be one of: ${allowedFormats.join(', ')}`); + this.value = ''; + return; + } + + // Check dimensions + const img = new Image(); + img.src = URL.createObjectURL(file); + + img.onload = function() { + URL.revokeObjectURL(this.src); + + if (this.width < minDimension || this.height < minDimension) { + alert(`Image dimensions must be at least ${minDimension}x${minDimension} pixels`); + e.target.value = ''; + return; + } + + if (this.width > maxDimension || this.height > maxDimension) { + alert(`Image dimensions must not exceed ${maxDimension}x${maxDimension} pixels`); + e.target.value = ''; + return; + } + }; + }); {% endblock %}
ID Approach Returns to same page Preserves form inputs
1 Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" button No YesLow
2 Validate in FastAPI endpoint function body, redirect to origin page with error message query param Yes NoMedium
3 Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputs Yes YesHigh
4 Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputs Yes YesHigh
5 Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level Javascript Yes Yes