Compare commits

...

5 Commits

27 changed files with 4619 additions and 186 deletions

103
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,103 @@
# HXBooks Project Guidelines
## Project Overview
HXBooks is a personal book library management application built with Flask, HTMX, and SQLAlchemy. It provides dynamic book searching, reading tracking, and library management without heavy JavaScript frameworks.
**Core Technologies:**
- **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations)
- **Frontend**: HTMX + Alpine.js + Bootstrap (minimal JavaScript approach)
- **Validation**: Pydantic 2.x schemas for request/response validation
- **Templates**: Jinja2 with fragments for partial page updates
- **Database**: SQLite with JSON columns for flexible arrays
- **Package Manager**: UV with Python 3.14
## Architecture
**Application Factory Pattern:**
- `create_app()` in [src/hxbooks/__init__.py](src/hxbooks/__init__.py)
- Blueprint organization: `auth.py` (authentication), `book.py` (main features)
- Models: User, Book, Reading, Wishlist with cascade relationships
**Key Components:**
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with modern `Mapped[]` syntax
- [src/hxbooks/book.py](src/hxbooks/book.py): Complex search with Pydantic validation
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
- [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments
## Build and Development
**Setup & Run:**
```bash
uv sync # Install dependencies
python -m hxbooks # Dev server with livereload (port 5000)
```
**Development Server:**
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates
- VS Code debugging: Use "Python Debugger: Flask" launch configuration
- Config: Instance folder pattern (`instance/config.py` for local overrides)
**Production Deployment:**
- Dockerfile uses Gunicorn on port 8080
- **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml`
## Code Style
**Python Conventions:**
- **Types**: Modern annotations (`str | Response`, `Mapped[str]`, `Optional[Type]`)
- **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants
- **SQLAlchemy**: Use `mapped_column()` and `relationship()` with `back_populates`
- **Validation**: Pydantic schemas with `{Entity}RequestSchema`/`{Entity}ResultSchema` pattern
**Flask Patterns:**
- Blueprints with `@bp.route()` decorators
- `@login_required` decorator for protected routes
- Response types: `str | Response` (template string or redirect)
- Error handling: Dict mapping for template display `{field: error_msg}`
**Frontend (HTMX + Alpine.js):**
- Templates: `.j2` extension, fragments for partial updates
- HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target`
- Alpine.js: Minimal state management (`x-data`, `x-show`, `@click`)
- Styling: Bootstrap CSS classes
## Key Files
**Entry Points:**
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Development server
- [main.py](main.py): Unused placeholder
- [.vscode/launch.json](.vscode/launch.json): Debug configuration
**Core Application:**
- [src/hxbooks/__init__.py](src/hxbooks/__init__.py): Flask app factory
- [src/hxbooks/db.py](src/hxbooks/db.py): SQLAlchemy setup with auto-table creation
- [src/hxbooks/models.py](src/hxbooks/models.py): Database models
**Feature Modules:**
- [src/hxbooks/auth.py](src/hxbooks/auth.py): User authentication (username-based)
- [src/hxbooks/book.py](src/hxbooks/book.py): Book CRUD, search, filtering
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
## Conventions
**Database Patterns:**
- JSON columns for arrays: `authors: list`, `genres: list`, `saved_searches: dict`
- Cascade deletes for dependent entities
- Foreign key constraints explicitly defined
**Search Implementation:**
- Full-text search using SQLite FTS with `func.match()`
- Complex query builder returning `Select` statements
- Saved searches stored as JSON in User model
**HTMX Integration:**
- Partial page updates using Jinja2-Fragments
- Search results rendered as blocks without full page reload
- Form validation errors returned as template fragments
**Development Notes:**
- No testing framework configured yet
- No linting/formatting tools setup
- Instance folder for environment-specific config (gitignored)
- Production requires either `requirements.txt` generation or Dockerfile updates for UV

3
.gitignore vendored
View File

@@ -1 +1,2 @@
instance/
instance/
*.sqlite

15
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.10.10
hooks:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.6
hooks:
# Run the linter.
- id: ruff-check
# Run the formatter.
- id: ruff-format

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

0
README.md Normal file
View File

247
docs/development-plan.md Normal file
View File

@@ -0,0 +1,247 @@
# HXBooks Development Plan & Progress
## User's Priorities (March 2026)
1.**Fix the domain and data model**
2.**Make sure everything related to the database is good**
3.**Make a CLI so I can test things manually**
4.**Make sure search and other basic functionality is good and can be accessed through CLI**
5.**Set up automated tests**
6. **Make sure search and other basic functionality is good**
7. **Fully rework the GUI**
*Everything else will come later.*
---
## ✅ COMPLETED: Domain Model & Database (Phase 1-2)
### Domain Model Decisions Made
- **No book/instance separation**: Keep it simple, treat duplicate editions as separate books
- **Author/Genre relationships**: Proper many-to-many instead of JSON fields
- **Location hierarchy**: `location_place` + `location_bookshelf` + `location_shelf` (numeric)
- **Auto-complete approach**: Authors/genres created on-demand with nice UI later
- **Multiple readings**: Separate records per reading session
- **Simple loaning**: `loaned_to` string + `loaned_date` for tracking
### Database Infrastructure ✅ DONE
- ✅ Flask-Migrate + Alembic set up
- ✅ Initial migration created and applied
- ✅ Fixed instance folder location (project root instead of src/instance)
- ✅ Database in correct location: `/hxbooks.sqlite`
- ✅ All tables created: author, genre, book, book_author, book_genre, reading, wishlist
### New Data Model ✅ IMPLEMENTED
```sql
-- Core entities
Author(id, name)
Genre(id, name)
Book(id, title, description, isbn, edition, publisher, notes,
added_date, bought_date,
location_place, location_bookshelf, location_shelf,
loaned_to, loaned_date, owner_id)
-- Many-to-many relationships
BookAuthor(book_id, author_id)
BookGenre(book_id, genre_id)
-- User activity
Reading(id, user_id, book_id, start_date, end_date,
finished, dropped, rating, comments)
Wishlist(id, user_id, book_id, wishlisted_date)
```
---
## ✅ COMPLETED: CLI Development (Phase 3)
### CLI Implementation ✅ DONE
-**Business logic separation**: Clean `services.py` module independent from web concerns
-**Book CRUD operations**: Create, read, update, delete books with proper validation
-**Author/Genre management**: Auto-create on-demand with many-to-many relationships
-**Location management**: Place, bookshelf, shelf hierarchy with filtering
-**Reading tracking**: Start, finish, drop, rate reading sessions
-**Wishlist operations**: Add, remove, list wishlist items
-**Advanced search**: pyparsing-based query language with field filters and comparison operators
-**ISBN import**: Google Books API integration for book metadata
-**Database utilities**: Status, initialization, seed data commands
-**Output formats**: Human-readable tables and JSON for scripting
### Advanced Search Language ✅ IMPLEMENTED
```bash
# Working CLI commands:
hxbooks book add "Title" --owner alice --authors "Author1,Author2" --genres "Fiction"
hxbooks book list --place "home" --bookshelf "office" --shelf 2
hxbooks book search "author:tolkien genre:fantasy"
hxbooks book search "shelf>=5 title:\"Lord of Rings\""
hxbooks book search -- "-genre:romance" # Negation
hxbooks reading start <book_id> --owner alice
hxbooks reading finish <book_id> --rating 4 --comments "Great book!"
hxbooks wishlist add <book_id> --owner alice
hxbooks book import 9780441172719 --owner alice # ISBN import
```
### Search Query Language Features
- **Field-specific searches**: `author:tolkien`, `genre:"science fiction"`
- **Comparison operators**: `shelf>=5`, `added>=2025-01-01`, `rating>3`
- **Quoted strings**: `title:"The Lord of the Rings"`
- **Negation**: `-genre:romance`
- **Date comparisons**: `added>=2026-03-01`, `bought<2025-12-31`
- **Multiple filters**: `author:herbert genre:scifi owner:alice`
---
## ✅ COMPLETED: Automated Testing (Phase 4)
### Testing Framework ✅ IMPLEMENTED
-**pytest infrastructure**: Database fixtures, isolated test environments
-**CLI command tests**: All 18 commands with happy paths and error scenarios (29+ tests)
-**Advanced search tests**: Parametrized tests for field filters and complex queries
-**Query parser unit tests**: Type conversion, operator logic, edge cases (36 tests)
-**Output format validation**: JSON and table formats for all commands
-**Database integration**: Full CLI → services → database → relationships flow testing
-**Error handling tests**: Invalid inputs, missing data, constraint violations
### Test Coverage Achieved
- **CLI Integration**: Book CRUD, reading tracking, wishlist operations, database utilities
- **Search functionality**: String filters, numeric filters, date filters, negation, complex queries
- **Parser robustness**: Edge cases, type conversion, fallback behavior, unicode support
- **Database validation**: Relationship integrity, user creation, data persistence
**Decision**: Migration tests deemed unnecessary for this simple personal app
**Status**: 65+ tests passing, comprehensive coverage for critical functionality
---
## 📋 TODO: Remaining Phases
### Phase 5: Search & Core Features Enhancement
- [ ] Full-text search (FTS) integration with SQLite
- [ ] Search result pagination and sorting
- [ ] Boolean operators (AND, OR, NOT) in search queries
- [ ] Parentheses grouping: `(genre:fantasy OR genre:scifi) AND rating>=4`
- [ ] Search performance optimization with proper indexes
- [ ] Autocomplete for field values (authors, genres, locations)
- [ ] Search result highlighting and snippets
- [ ] Saved search management improvements
### Phase 6: GUI Rework
- [ ] Update templates for new data model
- [ ] Mobile-first responsive design
- [ ] Author/Genre autocomplete interfaces
- [ ] Location hierarchy picker
- [ ] Touch-optimized interactions
---
## 🗄️ Original Critique Archive
### Critical Issues RESOLVED ✅
-**Book ownership model**: Fixed - no artificial scarcity
-**JSON denormalization**: Fixed - proper Author/Genre relationships
-**Mixed properties**: Fixed - structured location hierarchy
-**No migrations**: Fixed - Alembic set up and working
-**Poor folder structure**: Fixed - database in project root
### Issues for Later Phases
- **Authentication**: Username-only insufficient (Phase 6+)
- **Configuration management**: No environment handling (Phase 6+)
- **Mobile UX**: Tables don't work on mobile (Phase 6)
- **Testing infrastructure**: No framework yet (Phase 5)
- **Error handling**: No proper boundaries (Phase 6+)
- **Performance**: No indexing strategy yet (Phase 4)
---
*Last updated: March 16, 2026*
*Status: Phases 1-4 Complete ✅ | Ready for Phase 5 🚀*
### Medium Priority Issues (Priority 3-4: CLI & Search)
#### Search & Discovery
- **Limited FTS capabilities**: Current implementation incomplete
- **No faceted search**: Missing filters by author, genre, year, etc.
- **Performance concerns**: JSON contains operations will be slow
- **Missing recommendations**: No "similar books" functionality
#### CLI Requirements for Testing
- Book CRUD operations
- Search functionality testing
- User management
- Reading tracking
- Data import/export capabilities
### Lower Priority Issues (Priority 5-6: Testing & GUI)
#### Testing Infrastructure Missing
- No testing framework configured
- No test database setup
- No fixtures or mock data
- No CI/CD pipeline
#### GUI/UX Issues
- Mobile responsiveness needs work
- No offline capabilities
- Tables don't work well on mobile
- Missing accessibility features
### Security & DevOps (Future)
- **Authentication**: Username-only is insufficient
- **Configuration management**: No environment handling
- **Deployment**: Dockerfile/requirements.txt mismatch
- **Secret management**: Hardcoded dev secrets
### Technical Debt
- **Python 3.14 requirement**: Too aggressive (doesn't exist yet)
- **Error handling**: No proper error boundaries
- **Logging**: No production logging configuration
- **Code quality**: Missing linting, formatting tools
---
## Implementation Notes
### Phase 1: Domain Model Rework
- [ ] Design new schema with proper relationships
- [ ] Create migration system
- [ ] Implement Author and Genre entities
- [ ] Separate Book from BookInstance
- [ ] Update all models with proper typing
### Phase 2: Database Infrastructure
- [ ] Set up Alembic migrations
- [ ] Add proper indexing
- [ ] Implement FTS correctly
- [ ] Add database constraints
- [ ] Create seed data
### Phase 3: CLI Development
- [ ] Create Click-based CLI
- [ ] Book management commands
- [ ] Search functionality
- [ ] User operations
- [ ] Import/export tools
### Phase 4: Search & Core Features
- [ ] Implement proper FTS
- [ ] Add faceted search
- [ ] Create search result serializers
- [ ] Add pagination
- [ ] Optimize query performance
### Phase 5: Testing Framework
- [ ] Set up pytest
- [ ] Database testing fixtures
- [ ] API endpoint tests
- [ ] Search functionality tests
- [ ] CLI command tests
### Phase 6: GUI Rework
- [ ] Mobile-first responsive design
- [ ] Progressive enhancement
- [ ] Accessibility improvements
- [ ] Modern HTMX patterns
- [ ] Touch-optimized interactions
---
*Last updated: March 14, 2026*

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

108
migrations/env.py Normal file
View File

@@ -0,0 +1,108 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions["migrate"].db.get_engine()
except TypeError, AttributeError:
# this works with Flask-SQLAlchemy>=3
return current_app.extensions["migrate"].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
except AttributeError:
return str(get_engine().url).replace("%", "%%")
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option("sqlalchemy.url", get_engine_url())
target_db = current_app.extensions["migrate"].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, "metadatas"):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
conf_args = current_app.extensions["migrate"].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=get_metadata(), **conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,143 @@
"""Initial migration
Revision ID: 75e81e4ab7b6
Revises:
Create Date: 2026-03-14 22:51:20.059755
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "75e81e4ab7b6"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"author",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"genre",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(), nullable=False),
sa.Column("saved_searches", sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"book",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.String(), nullable=False),
sa.Column("first_published", sa.Integer(), nullable=True),
sa.Column("edition", sa.String(length=200), nullable=False),
sa.Column("publisher", sa.String(length=200), nullable=False),
sa.Column("isbn", sa.String(length=20), nullable=False),
sa.Column("notes", sa.String(), nullable=False),
sa.Column("added_date", sa.DateTime(), nullable=False),
sa.Column("bought_date", sa.Date(), nullable=True),
sa.Column("location_place", sa.String(length=100), nullable=False),
sa.Column("location_bookshelf", sa.String(length=100), nullable=False),
sa.Column("location_shelf", sa.Integer(), nullable=True),
sa.Column("loaned_to", sa.String(length=200), nullable=False),
sa.Column("loaned_date", sa.Date(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["owner_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"book_author",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["author_id"],
["author.id"],
),
sa.ForeignKeyConstraint(
["book_id"],
["book.id"],
),
sa.PrimaryKeyConstraint("book_id", "author_id"),
)
op.create_table(
"book_genre",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("genre_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
["book.id"],
),
sa.ForeignKeyConstraint(
["genre_id"],
["genre.id"],
),
sa.PrimaryKeyConstraint("book_id", "genre_id"),
)
op.create_table(
"reading",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("start_date", sa.Date(), nullable=False),
sa.Column("end_date", sa.Date(), nullable=True),
sa.Column("finished", sa.Boolean(), nullable=False),
sa.Column("dropped", sa.Boolean(), nullable=False),
sa.Column("rating", sa.Integer(), nullable=True),
sa.Column("comments", sa.String(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
["book.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"wishlist",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("wishlisted_date", sa.Date(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
["book.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("wishlist")
op.drop_table("reading")
op.drop_table("book_genre")
op.drop_table("book_author")
op.drop_table("book")
op.drop_table("user")
op.drop_table("genre")
op.drop_table("author")
# ### end Alembic commands ###

View File

@@ -1,19 +1,63 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "hxbooks"
version = "0.1.0"
requires-python = ">=3.11"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"flask",
"flask_sqlalchemy",
"flask-htmx",
"jinja2_fragments",
"sqlalchemy",
"pydantic",
"requests",
"gunicorn",
"alembic>=1.13.0",
"click>=8.3.1",
"flask>=3.1.3",
"flask-htmx>=0.4.0",
"flask-migrate>=4.0.0",
"flask-sqlalchemy>=3.1.1",
"gunicorn>=25.1.0",
"jinja2-fragments>=1.11.0",
"pydantic>=2.12.5",
"pyparsing>=3.3.2",
"requests>=2.32.5",
"sqlalchemy>=2.0.48",
]
[project.scripts]
hxbooks = "hxbooks.cli:cli"
[build-system]
requires = ["uv_build>=0.10.10,<0.11.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pre-commit>=4.5.1",
"pytest>=9.0.2",
"ruff>=0.15.6",
"ty>=0.0.23",
]
[tool.pytest.ini_options]
addopts = ["-v", "--tb=short"]
[tool.ruff]
preview = true
exclude = [
"migrations/**",
"src/hxbooks/book.py",
"src/hxbooks/util.py",
"src/hxbooks/auth.py",
"src/hxbooks/gbooks.py",
]
[tool.ruff.lint]
select = ["E", "F", "B", "C90", "UP", "RUF", "FURB", "PL", "ANN"]
ignore = ["PLR09", "PLR2004", "E501", "C901", "PLC1901"]
per-file-ignores = { "tests/**.py" = ["PLR6301"] }
[tool.ty.src]
exclude = [
"migrations/**",
"src/hxbooks/book.py",
"src/hxbooks/util.py",
"src/hxbooks/auth.py",
"src/hxbooks/gbooks.py",
"src/hxbooks/htmx.py",
]

View File

@@ -1,64 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile
#
annotated-types==0.6.0
# via pydantic
blinker==1.7.0
# via flask
certifi==2024.2.2
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via flask
flask==3.0.3
# via
# flask-htmx
# flask-sqlalchemy
# hxbooks (pyproject.toml)
flask-htmx==0.3.2
# via hxbooks (pyproject.toml)
flask-sqlalchemy==3.1.1
# via hxbooks (pyproject.toml)
greenlet==3.0.3
# via sqlalchemy
gunicorn==22.0.0
# via hxbooks (pyproject.toml)
idna==3.7
# via requests
itsdangerous==2.2.0
# via flask
jinja2==3.1.3
# via
# flask
# jinja2-fragments
jinja2-fragments==1.3.0
# via hxbooks (pyproject.toml)
markupsafe==2.1.5
# via
# jinja2
# werkzeug
packaging==24.0
# via gunicorn
pydantic==2.7.1
# via hxbooks (pyproject.toml)
pydantic-core==2.18.2
# via pydantic
requests==2.31.0
# via hxbooks (pyproject.toml)
sqlalchemy==2.0.29
# via
# flask-sqlalchemy
# hxbooks (pyproject.toml)
typing-extensions==4.11.0
# via
# pydantic
# pydantic-core
# sqlalchemy
urllib3==2.2.1
# via requests
werkzeug==3.0.2
# via flask

View File

@@ -1,43 +0,0 @@
import os
from typing import Optional
from flask import Flask
from . import auth, book, db
from .htmx import htmx
def create_app(test_config: Optional[dict] = None) -> Flask:
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY="dev",
SQLALCHEMY_DATABASE_URI="sqlite:///hxbooks.sqlite",
)
if test_config is None:
# load the instance config, if it exists, when not testing
app.config.from_pyfile("config.py", silent=True)
else:
# load the test config if passed in
app.config.from_mapping(test_config)
# ensure the instance folder exists
try:
os.makedirs(app.instance_path)
except OSError:
pass
db.init_app(app)
htmx.init_app(app)
app.register_blueprint(auth.bp)
app.register_blueprint(book.bp)
app.add_url_rule("/", endpoint="books.books")
return app
if __name__ == "__main__":
app = create_app()
app.run()

View File

@@ -1,6 +1,6 @@
import livereload # type: ignore
from hxbooks import create_app
from hxbooks.app import create_app
app = create_app()
app.debug = True

48
src/hxbooks/app.py Normal file
View File

@@ -0,0 +1,48 @@
import os
from pathlib import Path
from flask import Flask
from flask_migrate import Migrate
from . import auth, book, db
from .htmx import htmx
# Get the project root (parent of src/)
PROJECT_ROOT = Path(__file__).parent.parent.parent
def create_app(test_config: dict | None = None) -> Flask:
# Set instance folder to project root/instance
app = Flask(__name__, instance_path=str(PROJECT_ROOT / "instance"))
app.config.from_mapping(
SECRET_KEY="dev",
# Put database in project root
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
)
if test_config is None:
# load the instance config, if it exists, when not testing
app.config.from_pyfile("config.py", silent=True)
else:
# load the test config if passed in
app.config.from_mapping(test_config)
# ensure the instance folder exists
try:
os.makedirs(app.instance_path)
except OSError:
pass
db.init_app(app)
htmx.init_app(app)
# Initialize migrations
Migrate(app, db.db)
app.register_blueprint(auth.bp)
app.register_blueprint(book.bp)
app.add_url_rule("/", endpoint="books.books")
return app

View File

@@ -64,19 +64,19 @@ ResultColumn = Literal[
class SearchRequestSchema(BaseModel, extra="forbid"):
q: str = ""
wishlisted: Optional[bool] = None
read: Optional[bool] = None
reading: Optional[bool] = None
dropped: Optional[bool] = None
bought_start: Optional[date] = None
bought_end: Optional[date] = None
started_reading_start: Optional[date] = None
started_reading_end: Optional[date] = None
finished_reading_start: Optional[date] = None
finished_reading_end: Optional[date] = None
wishlisted: bool | None = None
read: bool | None = None
reading: bool | None = None
dropped: bool | None = None
bought_start: date | None = None
bought_end: date | None = None
started_reading_start: date | None = None
started_reading_end: date | None = None
finished_reading_start: date | None = None
finished_reading_end: date | None = None
sort_by: ResultColumn = "title"
sort_order: Literal["asc", "desc"] = "asc"
saved_search: Optional[str] = None
saved_search: str | None = None
@field_validator(
"wishlisted",
@@ -104,13 +104,13 @@ class BookResultSchema(BaseModel):
authors: list[str]
genres: list[str]
publisher: str
first_published: Optional[int]
first_published: int | None
edition: str
added: datetime
description: str
notes: str
isbn: str
owner: Optional[str]
owner: str | None
bought: date
location: str
loaned_to: str
@@ -119,8 +119,8 @@ class BookResultSchema(BaseModel):
read: bool
reading: bool
dropped: bool
started_reading: Optional[date]
finished_reading: Optional[date]
started_reading: date | None
finished_reading: date | None
@bp.route("", methods=["GET"])
@@ -377,12 +377,10 @@ def get_default_searches(username: str) -> dict[str, SearchRequestSchema]:
def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
searches = get_default_searches(user.username).copy()
searches.update(
{
name: SearchRequestSchema.model_validate(value)
for name, value in user.saved_searches.items()
}
)
searches.update({
name: SearchRequestSchema.model_validate(value)
for name, value in user.saved_searches.items()
})
for name, search in searches.items():
search.saved_search = name
return searches
@@ -390,14 +388,14 @@ def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
class BookRequestSchema(BaseModel):
title: str = Field(min_length=1)
first_published: Optional[int] = None
first_published: int | None = None
edition: str = ""
notes: str = ""
isbn: str = ""
authors: list[str] = []
genres: list[str] = []
publisher: str = ""
owner_id: Optional[int] = None
owner_id: int | None = None
bought: date = Field(default_factory=datetime.today)
location: str = "billy salon"
loaned_to: str = ""
@@ -456,7 +454,8 @@ def book(id: int) -> str | Response:
"users": db.session.execute(select(User)).scalars().all(),
"genres": get_distinct_json_list_values(Book.genres),
"authors": get_distinct_json_list_values(Book.authors),
"locations": db.session.execute(select(Book.location).distinct())
"locations": db.session
.execute(select(Book.location).distinct())
.scalars()
.all(),
"wished_by": [wishlist.user.username for wishlist in book.wished_by],
@@ -481,10 +480,10 @@ def readings_new(id: int) -> str:
class ReadingRequestSchema(BaseModel):
start_date: date = Field(default_factory=datetime.today)
end_date: Optional[date] = None
end_date: date | None = None
finished: bool = False
dropped: bool = False
rating: Optional[int] = None
rating: int | None = None
comments: str = ""
user_id: int
book_id: int

633
src/hxbooks/cli.py Normal file
View File

@@ -0,0 +1,633 @@
"""
HXBooks CLI - Command line interface for library management.
Provides commands for book management, reading tracking, and search functionality
while keeping business logic separate from web interface concerns.
"""
import json
import sys
import click
from flask import Flask
from . import library
from .app import create_app
from .db import db
from .models import Author, Book, Genre, Reading, User, Wishlist
def get_app() -> Flask:
"""Create and configure Flask app for CLI operations."""
return create_app()
def ensure_user_exists(app: Flask, username: str) -> int:
"""Ensure a user exists and return their ID."""
with app.app_context():
user = db.session.execute(
db.select(User).filter_by(username=username)
).scalar_one_or_none()
if user is None:
user = User(username=username)
db.session.add(user)
db.session.commit()
click.echo(f"Created user: {username}")
return user.id
@click.group()
@click.version_option()
def cli() -> None:
"""HXBooks - Personal library management system."""
pass
@cli.group()
def book() -> None:
"""Book management commands."""
pass
@cli.group()
def reading() -> None:
"""Reading tracking commands."""
pass
@cli.group()
def wishlist() -> None:
"""Wishlist management commands."""
pass
@cli.group("db")
def db_group() -> None:
"""Database management commands."""
pass
# Book commands
@book.command("add")
@click.argument("title")
@click.option("--owner", help="Username of book owner")
@click.option("--authors", help="Comma-separated list of authors")
@click.option("--genres", help="Comma-separated list of genres")
@click.option("--isbn", help="ISBN number")
@click.option("--publisher", help="Publisher name")
@click.option("--edition", help="Edition information")
@click.option("--place", help="Location place (e.g., 'home', 'office')")
@click.option("--bookshelf", help="Bookshelf name")
@click.option("--shelf", type=int, help="Shelf number")
@click.option("--description", help="Book description")
@click.option("--notes", help="Personal notes")
def add_book(
title: str,
owner: str,
authors: str | None = None,
genres: str | None = None,
isbn: str | None = None,
publisher: str | None = None,
edition: str | None = None,
place: str | None = None,
bookshelf: str | None = None,
shelf: int | None = None,
description: str | None = None,
notes: str | None = None,
) -> None:
"""Add a new book to the library."""
app = get_app()
if owner:
user_id = ensure_user_exists(app, owner)
else:
user_id = None
with app.app_context():
try:
book = library.create_book(
title=title,
owner_id=user_id,
authors=authors.split(",") if authors else None,
genres=genres.split(",") if genres else None,
isbn=isbn,
publisher=publisher,
edition=edition,
location_place=place,
location_bookshelf=bookshelf,
location_shelf=shelf,
description=description,
notes=notes,
)
click.echo(f"Added book: {book.title} (ID: {book.id})")
except Exception as e:
click.echo(f"Error adding book: {e}", err=True)
sys.exit(1)
@book.command("list")
@click.option("--owner", help="Filter by owner username")
@click.option("--place", help="Filter by location place")
@click.option("--bookshelf", help="Filter by bookshelf")
@click.option("--shelf", type=int, help="Filter by shelf number")
@click.option(
"--format",
"output_format",
type=click.Choice(["table", "json"]),
default="table",
help="Output format",
)
@click.option("--limit", type=int, default=50, help="Maximum number of books to show")
def list_books(
owner: str | None = None,
place: str | None = None,
bookshelf: str | None = None,
shelf: int | None = None,
output_format: str = "table",
limit: int = 50,
) -> None:
"""List books in the library."""
app = get_app()
with app.app_context():
try:
books = library.search_books(
owner_username=owner,
location_place=place,
location_bookshelf=bookshelf,
location_shelf=shelf,
limit=limit,
)
if output_format == "json":
book_data = []
for book in books:
book_data.append({
"id": book.id,
"title": book.title,
"authors": [a.name for a in book.authors],
"genres": [g.name for g in book.genres],
"owner": book.owner.username if book.owner else None,
"location": f"{book.location_place}/{book.location_bookshelf}/{book.location_shelf}",
"isbn": book.isbn,
})
click.echo(json.dumps(book_data, indent=2))
else:
# Table format
if not books:
click.echo("No books found.")
return
click.echo(f"{'ID':<4} {'Title':<30} {'Authors':<25} {'Owner':<12}")
click.echo("-" * 75)
for book in books:
authors_str = ", ".join(a.name for a in book.authors)[:22]
if len(authors_str) == 22:
authors_str += "..."
owner_str = book.owner.username if book.owner else ""
click.echo(
f"{book.id:<4} {book.title[:27]:<30} {authors_str:<25} {owner_str:<12}"
)
except Exception as e:
click.echo(f"Error listing books: {e}", err=True)
sys.exit(1)
@book.command("search")
@click.argument("query")
@click.option(
"--username", help="Username to apply user-specific filters (e.g., for ratings)"
)
@click.option(
"--format",
"output_format",
type=click.Choice(["table", "json"]),
default="table",
help="Output format",
)
@click.option("--limit", type=int, default=20, help="Maximum number of results")
def search_books(
query: str,
username: str | None = None,
output_format: str = "table",
limit: int = 20,
) -> None:
"""Search books using query language (e.g., 'genre:thriller read>=2025-01-01')."""
app = get_app()
with app.app_context():
try:
books = library.search_books_advanced(
query_string=query, limit=limit, username=username
)
if output_format == "json":
results = []
for book in books:
results.append({
"id": book.id,
"title": book.title,
"authors": [author.name for author in book.authors],
"genres": [genre.name for genre in book.genres],
"owner": book.owner.username if book.owner else None,
"isbn": book.isbn,
"publisher": book.publisher,
"description": book.description,
"location": {
"place": book.location_place,
"bookshelf": book.location_bookshelf,
"shelf": book.location_shelf,
},
"loaned_to": book.loaned_to,
"loaned_date": book.loaned_date.isoformat()
if book.loaned_date
else None,
"added_date": book.added_date.isoformat(),
"bought_date": book.bought_date.isoformat()
if book.bought_date
else None,
})
click.echo(json.dumps(results, indent=2))
else:
# Table format
if not books:
click.echo("No books found.")
return
click.echo(f"{'ID':<4} {'Title':<35} {'Authors':<30}")
click.echo("-" * 72)
for book in books:
authors_str = ", ".join(a.name for a in book.authors)[:27]
if len(authors_str) == 27:
authors_str += "..."
click.echo(f"{book.id:<4} {book.title[:32]:<35} {authors_str:<30}")
except Exception as e:
click.echo(f"Error searching books: {e}", err=True)
raise
@book.command("import")
@click.argument("isbn")
@click.option("--owner", required=True, help="Username of book owner")
@click.option("--place", help="Location place")
@click.option("--bookshelf", help="Bookshelf name")
@click.option("--shelf", type=int, help="Shelf number")
def import_book(
isbn: str,
owner: str,
place: str | None = None,
bookshelf: str | None = None,
shelf: int | None = None,
) -> None:
"""Import book data from ISBN using Google Books API."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
try:
book = library.import_book_from_isbn(
isbn=isbn,
owner_id=user_id,
location_place=place,
location_bookshelf=bookshelf,
location_shelf=shelf,
)
click.echo(
f"Imported book: {book.title} by {', '.join(a.name for a in book.authors)} (ID: {book.id})"
)
except Exception as e:
click.echo(f"Error importing book: {e}", err=True)
sys.exit(1)
# Reading commands
@reading.command("start")
@click.argument("book_id", type=int)
@click.option("--owner", required=True, help="Username of reader")
def start_reading(book_id: int, owner: str) -> None:
"""Start a new reading session for a book."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
try:
reading_session = library.start_reading(book_id=book_id, user_id=user_id)
click.echo(
f"Started reading session {reading_session.id} for book {book_id}"
)
except Exception as e:
click.echo(f"Error starting reading: {e}", err=True)
sys.exit(1)
@reading.command("finish")
@click.argument("reading_id", type=int)
@click.option("--rating", type=click.IntRange(1, 5), help="Rating from 1-5")
@click.option("--comments", help="Reading comments")
def finish_reading(
reading_id: int, rating: int | None = None, comments: str | None = None
) -> None:
"""Finish a reading session."""
app = get_app()
with app.app_context():
try:
reading_session = library.finish_reading(
reading_id=reading_id,
rating=rating,
comments=comments,
)
book_title = reading_session.book.title
click.echo(f"Finished reading: {book_title}")
if rating:
click.echo(f"Rating: {rating}/5")
except Exception as e:
click.echo(f"Error finishing reading: {e}", err=True)
sys.exit(1)
@reading.command("drop")
@click.argument("reading_id", type=int)
@click.option("--comments", help="Comments about why dropped")
def drop_reading(reading_id: int, comments: str | None = None) -> None:
"""Mark a reading session as dropped."""
app = get_app()
with app.app_context():
try:
reading_session = library.drop_reading(
reading_id=reading_id, comments=comments
)
book_title = reading_session.book.title
click.echo(f"Dropped reading: {book_title}")
except Exception as e:
click.echo(f"Error dropping reading: {e}", err=True)
sys.exit(1)
@reading.command("list")
@click.option("--owner", required=True, help="Username to show readings for")
@click.option("--current", is_flag=True, help="Show only current (unfinished) readings")
@click.option(
"--format",
"output_format",
type=click.Choice(["table", "json"]),
default="table",
help="Output format",
)
def list_readings(
owner: str, current: bool = False, output_format: str = "table"
) -> None:
"""List reading sessions."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
try:
if current:
readings = library.get_current_readings(user_id=user_id)
else:
readings = library.get_reading_history(user_id=user_id)
if output_format == "json":
reading_data = []
for reading in readings:
reading_data.append({
"id": reading.id,
"book_id": reading.book_id,
"book_title": reading.book.title,
"start_date": reading.start_date.isoformat(),
"end_date": reading.end_date.isoformat()
if reading.end_date
else None,
"finished": reading.finished,
"dropped": reading.dropped,
"rating": reading.rating,
"comments": reading.comments,
})
click.echo(json.dumps(reading_data, indent=2))
else:
# Table format
if not readings:
msg = "No current readings." if current else "No reading history."
click.echo(msg)
return
click.echo(
f"{'ID':<4} {'Book':<30} {'Started':<12} {'Status':<10} {'Rating':<6}"
)
click.echo("-" * 65)
for reading in readings:
status = (
"Reading"
if not reading.end_date
else ("Finished" if reading.finished else "Dropped")
)
rating = f"{reading.rating}/5" if reading.rating else ""
click.echo(
f"{reading.id:<4} {reading.book.title[:27]:<30} {reading.start_date.strftime('%Y-%m-%d'):<12} {status:<10} {rating:<6}"
)
except Exception as e:
click.echo(f"Error listing readings: {e}", err=True)
sys.exit(1)
# Wishlist commands
@wishlist.command("add")
@click.argument("book_id", type=int)
@click.option("--owner", required=True, help="Username")
def add_to_wishlist(book_id: int, owner: str) -> None:
"""Add a book to wishlist."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
try:
wishlist_item = library.add_to_wishlist(book_id=book_id, user_id=user_id)
book_title = wishlist_item.book.title
click.echo(f"Added '{book_title}' to wishlist")
except Exception as e:
click.echo(f"Error adding to wishlist: {e}", err=True)
sys.exit(1)
@wishlist.command("remove")
@click.argument("book_id", type=int)
@click.option("--owner", required=True, help="Username")
def remove_from_wishlist(book_id: int, owner: str) -> None:
"""Remove a book from wishlist."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
try:
if library.remove_from_wishlist(book_id=book_id, user_id=user_id):
click.echo(f"Removed book {book_id} from wishlist")
else:
click.echo(f"Book {book_id} was not in wishlist")
except Exception as e:
click.echo(f"Error removing from wishlist: {e}", err=True)
sys.exit(1)
@wishlist.command("list")
@click.option("--owner", required=True, help="Username")
@click.option(
"--format",
"output_format",
type=click.Choice(["table", "json"]),
default="table",
help="Output format",
)
def list_wishlist(owner: str, output_format: str = "table") -> None:
"""Show user's wishlist."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
try:
wishlist_items = library.get_wishlist(user_id=user_id)
if output_format == "json":
wishlist_data = []
for item in wishlist_items:
wishlist_data.append({
"book_id": item.book_id,
"title": item.book.title,
"authors": [author.name for author in item.book.authors],
"wishlisted_date": item.wishlisted_date.isoformat(),
})
click.echo(json.dumps(wishlist_data, indent=2))
else:
# Table format
if not wishlist_items:
click.echo("Wishlist is empty.")
return
click.echo(f"{'ID':<4} {'Title':<35} {'Authors':<25} {'Added':<12}")
click.echo("-" * 78)
for item in wishlist_items:
authors_str = ", ".join(a.name for a in item.book.authors)[:22]
if len(authors_str) == 22:
authors_str += "..."
click.echo(
f"{item.book_id:<4} {item.book.title[:32]:<35} {authors_str:<25} {item.wishlisted_date.strftime('%Y-%m-%d'):<12}"
)
except Exception as e:
click.echo(f"Error listing wishlist: {e}", err=True)
sys.exit(1)
# Database commands
@db_group.command("init")
def init_db() -> None:
"""Initialize the database."""
app = get_app()
with app.app_context():
db.create_all()
click.echo("Database initialized.")
@db_group.command("seed")
@click.option("--owner", default="test_user", help="Default owner for seed data")
def seed_db(owner: str) -> None:
"""Create some sample data for testing."""
app = get_app()
user_id = ensure_user_exists(app, owner)
with app.app_context():
sample_books = [
{
"title": "The Hobbit",
"authors": ["J.R.R. Tolkien"],
"genres": ["Fantasy", "Adventure"],
"publisher": "Allen & Unwin",
"description": "A hobbit's unexpected journey to help a group of dwarves reclaim their homeland.",
"location_place": "home",
"location_bookshelf": "fantasy",
"location_shelf": 1,
},
{
"title": "Dune",
"authors": ["Frank Herbert"],
"genres": ["Science Fiction"],
"publisher": "Chilton Books",
"description": "A science fiction epic set in the distant future on the desert planet Arrakis.",
"location_place": "home",
"location_bookshelf": "sci-fi",
"location_shelf": 2,
},
{
"title": "The Pragmatic Programmer",
"authors": ["David Thomas", "Andrew Hunt"],
"genres": ["Technology", "Programming"],
"publisher": "Addison-Wesley",
"description": "From journeyman to master - essential programming techniques.",
"location_place": "office",
"location_bookshelf": "tech",
"location_shelf": 1,
},
]
created_books = []
for book_data in sample_books:
try:
book = library.create_book(owner_id=user_id, **book_data) # ty:ignore[invalid-argument-type]
created_books.append(book)
click.echo(f"Created: {book.title}")
except Exception as e:
click.echo(f"Error creating book '{book_data['title']}': {e}")
click.echo(f"Created {len(created_books)} sample books for user '{owner}'")
@db_group.command("status")
def db_status() -> None:
"""Show database status and statistics."""
app = get_app()
with app.app_context():
try:
book_count = db.session.execute(db.select(db.func.count(Book.id))).scalar()
author_count = db.session.execute(
db.select(db.func.count(Author.id))
).scalar()
genre_count = db.session.execute(
db.select(db.func.count(Genre.id))
).scalar()
user_count = db.session.execute(db.select(db.func.count(User.id))).scalar()
reading_count = db.session.execute(
db.select(db.func.count(Reading.id))
).scalar()
wishlist_count = db.session.execute(
db.select(db.func.count(Wishlist.id))
).scalar()
click.echo("Database Statistics:")
click.echo(f" Books: {book_count}")
click.echo(f" Authors: {author_count}")
click.echo(f" Genres: {genre_count}")
click.echo(f" Users: {user_count}")
click.echo(f" Reading sessions: {reading_count}")
click.echo(f" Wishlist items: {wishlist_count}")
except Exception as e:
click.echo(f"Error getting database status: {e}", err=True)
sys.exit(1)
if __name__ == "__main__":
cli()

View File

@@ -11,5 +11,3 @@ db = SQLAlchemy(model_class=Base)
def init_app(app: Flask) -> None:
db.init_app(app)
with app.app_context():
db.create_all()

View File

@@ -1,5 +1,5 @@
from datetime import date, datetime
from typing import Any, Optional
from typing import Any
import requests
from pydantic import BaseModel, field_validator
@@ -53,7 +53,7 @@ class GoogleBook(BaseModel):
title: str
authors: list[str] = []
publisher: str = ""
publishedDate: Optional[date | int] = None
publishedDate: date | int | None = None
description: str = ""
industryIdentifiers: list[dict[str, str]] = []
pageCount: int = 0

726
src/hxbooks/library.py Normal file
View File

@@ -0,0 +1,726 @@
"""
Business logic services for HXBooks.
Clean service layer for book management, reading tracking, and wishlist operations.
Separated from web interface concerns to enable both CLI and web access.
"""
from collections.abc import Sequence
from datetime import date, datetime
from typing import assert_never
from sqlalchemy import ColumnElement, and_, or_
from sqlalchemy.orm import InstrumentedAttribute, joinedload
from hxbooks.search import IsOperatorValue, QueryParser, ValueT
from .db import db
from .gbooks import fetch_google_book_data
from .models import Author, Book, Genre, Reading, User, Wishlist
from .search import ComparisonOperator, Field, FieldFilter
def create_book(
title: str,
owner_id: int | None = None,
authors: list[str] | None = None,
genres: list[str] | None = None,
isbn: str | None = None,
publisher: str | None = None,
edition: str | None = None,
description: str | None = None,
notes: str | None = None,
location_place: str | None = None,
location_bookshelf: str | None = None,
location_shelf: int | None = None,
first_published: int | None = None,
bought_date: date | None = None,
) -> Book:
"""Create a new book with the given details."""
book = Book(
title=title,
owner_id=owner_id,
isbn=isbn or "",
publisher=publisher or "",
edition=edition or "",
description=description or "",
notes=notes or "",
location_place=location_place or "",
location_bookshelf=location_bookshelf or "",
location_shelf=location_shelf,
first_published=first_published,
bought_date=bought_date,
)
db.session.add(book)
# Handle authors
if authors:
for author_name in [a_strip for a in authors if (a_strip := a.strip())]:
author = _get_or_create_author(author_name)
book.authors.append(author)
# Handle genres
if genres:
for genre_name in [g_strip for g in genres if (g_strip := g.strip())]:
genre = _get_or_create_genre(genre_name)
book.genres.append(genre)
db.session.commit()
return book
def get_book(book_id: int) -> Book | None:
"""Get a book by ID with all relationships loaded."""
return db.session.execute(
db
.select(Book)
.options(
joinedload(Book.authors),
joinedload(Book.genres),
joinedload(Book.owner),
)
.filter(Book.id == book_id)
).scalar_one_or_none()
def update_book(
book_id: int,
title: str | None = None,
authors: list[str] | None = None,
genres: list[str] | None = None,
isbn: str | None = None,
publisher: str | None = None,
edition: str | None = None,
description: str | None = None,
notes: str | None = None,
location_place: str | None = None,
location_bookshelf: str | None = None,
location_shelf: int | None = None,
first_published: int | None = None,
bought_date: date | None = None,
) -> Book | None:
"""Update a book with new details."""
book = get_book(book_id)
if not book:
return None
# Update scalar fields
if title is not None:
book.title = title
if isbn is not None:
book.isbn = isbn
if publisher is not None:
book.publisher = publisher
if edition is not None:
book.edition = edition
if description is not None:
book.description = description
if notes is not None:
book.notes = notes
if location_place is not None:
book.location_place = location_place
if location_bookshelf is not None:
book.location_bookshelf = location_bookshelf
if location_shelf is not None:
book.location_shelf = location_shelf
if first_published is not None:
book.first_published = first_published
if bought_date is not None:
book.bought_date = bought_date
# Update authors
if authors is not None:
book.authors.clear()
for author_name in [a_strip for a in authors if (a_strip := a.strip())]:
author = _get_or_create_author(author_name)
book.authors.append(author)
# Update genres
if genres is not None:
book.genres.clear()
for genre_name in [g_strip for g in genres if (g_strip := g.strip())]:
genre = _get_or_create_genre(genre_name)
book.genres.append(genre)
db.session.commit()
return book
def delete_book(book_id: int) -> bool:
"""Delete a book and all related data."""
book = get_book(book_id)
if not book:
return False
db.session.delete(book)
db.session.commit()
return True
def search_books(
text_query: str | None = None,
owner_username: str | None = None,
location_place: str | None = None,
location_bookshelf: str | None = None,
location_shelf: int | None = None,
author_name: str | None = None,
genre_name: str | None = None,
isbn: str | None = None,
limit: int = 50,
) -> Sequence[Book]:
"""
Search books with various filters.
For now implements basic filtering - advanced query parsing will be added later.
"""
query = db.select(Book).options(
joinedload(Book.authors), joinedload(Book.genres), joinedload(Book.owner)
)
conditions = []
# Text search across multiple fields
if text_query:
text_query = text_query.strip()
if text_query:
text_conditions = []
# Search in title, description, notes
text_conditions.extend((
Book.title.icontains(text_query),
Book.description.icontains(text_query),
Book.notes.icontains(text_query),
Book.publisher.icontains(text_query),
Book.authors.any(Author.name.icontains(text_query)),
Book.genres.any(Genre.name.icontains(text_query)),
))
conditions.append(or_(*text_conditions))
# Owner filter
if owner_username:
conditions.append(Book.owner.has(User.username == owner_username))
# Location filters
if location_place:
conditions.append(Book.location_place.icontains(location_place))
if location_bookshelf:
conditions.append(Book.location_bookshelf.icontains(location_bookshelf))
if location_shelf is not None:
conditions.append(Book.location_shelf == location_shelf)
# Author filter
if author_name:
conditions.append(Book.authors.any(Author.name.icontains(author_name)))
# Genre filter
if genre_name:
conditions.append(Book.genres.any(Genre.name.icontains(genre_name)))
# ISBN filter
if isbn:
conditions.append(Book.isbn == isbn)
# Apply all conditions
if conditions:
query = query.filter(and_(*conditions))
query = query.distinct().limit(limit)
result = db.session.execute(query)
return result.scalars().unique().all()
query_parser = QueryParser()
def search_books_advanced(
query_string: str, limit: int = 50, username: str | None = None
) -> Sequence[Book]:
"""Advanced search with field filters supporting comparison operators."""
parsed_query = query_parser.parse(query_string)
query = db.select(Book).options(
joinedload(Book.authors), joinedload(Book.genres), joinedload(Book.owner)
)
conditions = []
# Text search across multiple fields (same as basic search)
if parsed_query.text_terms:
for text_query in [
t_strip for t in parsed_query.text_terms if (t_strip := t.strip())
]:
text_conditions = []
# Search in title, description, notes
text_conditions.extend((
Book.title.icontains(text_query),
Book.description.icontains(text_query),
Book.notes.icontains(text_query),
Book.publisher.icontains(text_query),
Book.authors.any(Author.name.icontains(text_query)),
Book.genres.any(Genre.name.icontains(text_query)),
))
conditions.append(or_(*text_conditions))
# Advanced field filters
if parsed_query.field_filters:
for field_filter in parsed_query.field_filters:
condition = _build_field_condition(field_filter, username)
if condition is not None:
if field_filter.negated:
condition = ~condition
conditions.append(condition)
# Apply all conditions
if conditions:
query = query.filter(and_(*conditions))
query = query.distinct().limit(limit)
result = db.session.execute(query)
return result.scalars().unique().all()
def _build_field_condition(
field_filter: FieldFilter, username: str | None = None
) -> ColumnElement | None:
"""
Build a SQLAlchemy condition for a field filter.
"""
field = field_filter.field
operator = field_filter.operator
value = field_filter.value
# Map field names to Book attributes or special handling
match field:
case Field.TITLE:
field_attr = Book.title
case Field.AUTHOR:
return Book.authors.any(_apply_operator(Author.name, operator, value))
case Field.GENRE:
return Book.genres.any(_apply_operator(Genre.name, operator, value))
case Field.ISBN:
field_attr = Book.isbn
case Field.PLACE:
field_attr = Book.location_place
case Field.BOOKSHELF:
field_attr = Book.location_bookshelf
case Field.SHELF:
field_attr = Book.location_shelf
case Field.ADDED_DATE:
field_attr = Book.added_date
case Field.BOUGHT_DATE:
field_attr = Book.bought_date
case Field.LOANED_DATE:
field_attr = Book.loaned_date
case Field.OWNER:
return Book.owner.has(_apply_operator(User.username, operator, value))
case Field.YEAR:
field_attr = Book.first_published
case Field.RATING:
any_condition = _apply_operator(Reading.rating, operator, value)
if username:
any_condition &= Reading.user.has(User.username == username)
return Book.readings.any(any_condition)
case Field.READ_DATE:
any_condition = _apply_operator(Reading.end_date, operator, value)
if username:
any_condition &= Reading.user.has(User.username == username)
return Book.readings.any(any_condition)
case Field.IS:
assert isinstance(value, IsOperatorValue)
match value:
case IsOperatorValue.LOANED:
return Book.loaned_to != ""
case IsOperatorValue.READING:
any_condition = Reading.end_date.is_(None)
if username:
any_condition &= Reading.user.has(User.username == username)
return Book.readings.any(any_condition)
case IsOperatorValue.READ:
any_condition = (~Reading.end_date.is_(None)) & Reading.dropped.is_(
False
)
if username:
any_condition &= Reading.user.has(User.username == username)
return Book.readings.any(any_condition)
case IsOperatorValue.DROPPED:
any_condition = (~Reading.end_date.is_(None)) & Reading.dropped.is_(
True
)
if username:
any_condition &= Reading.user.has(User.username == username)
return Book.readings.any(any_condition)
case IsOperatorValue.WISHED:
return Book.wished_by.any(
Wishlist.user.has(User.username == username)
if username
else None
)
case IsOperatorValue.UNKNOWN:
return None
case _:
assert_never(value)
case _:
assert_never(field)
condition = _apply_operator(field_attr, operator, value)
return condition
def _apply_operator(
field_attr: InstrumentedAttribute, operator: ComparisonOperator, value: ValueT
) -> ColumnElement:
"""Apply a comparison operator to a field attribute."""
if operator == ComparisonOperator.EQUALS:
if isinstance(value, str):
return field_attr.icontains(value) # Case-insensitive contains for strings
else:
return field_attr == value
elif operator == ComparisonOperator.GREATER:
return field_attr > value
elif operator == ComparisonOperator.GREATER_EQUAL:
return field_attr >= value
elif operator == ComparisonOperator.LESS:
return field_attr < value
elif operator == ComparisonOperator.LESS_EQUAL:
return field_attr <= value
elif operator == ComparisonOperator.NOT_EQUALS:
if isinstance(value, str):
return ~field_attr.icontains(value)
else:
return field_attr != value
else:
# Default to equals
return field_attr == value
def import_book_from_isbn(
isbn: str,
owner_id: int | None = None,
location_place: str | None = None,
location_bookshelf: str | None = None,
location_shelf: int | None = None,
) -> Book:
"""Import book data from Google Books API using ISBN."""
google_book_data = fetch_google_book_data(isbn)
if not google_book_data:
raise ValueError(f"No book data found for ISBN: {isbn}")
# Convert Google Books data to our format
authors = []
if google_book_data.authors:
authors = google_book_data.authors
genres = []
if google_book_data.categories:
genres = google_book_data.categories
first_published = None
if google_book_data.publishedDate:
if isinstance(google_book_data.publishedDate, date):
first_published = google_book_data.publishedDate.year
elif isinstance(google_book_data.publishedDate, int):
first_published = google_book_data.publishedDate
return create_book(
title=google_book_data.title,
owner_id=owner_id,
authors=authors,
genres=genres,
isbn=isbn,
publisher=google_book_data.publisher or "",
description=google_book_data.description or "",
first_published=first_published,
location_place=location_place,
location_bookshelf=location_bookshelf,
location_shelf=location_shelf,
)
def get_books_by_location(
place: str, bookshelf: str | None = None, shelf: int | None = None
) -> Sequence[Book]:
"""Get all books at a specific location."""
return search_books(
location_place=place,
location_bookshelf=bookshelf,
location_shelf=shelf,
limit=1000, # Large limit for location queries
)
def _get_or_create_author(name: str) -> Author:
"""Get existing author or create a new one."""
author = db.session.execute(
db.select(Author).filter(Author.name == name)
).scalar_one_or_none()
if author is None:
author = Author(name=name)
db.session.add(author)
# Don't commit here - let the caller handle the transaction
return author
def _get_or_create_genre(name: str) -> Genre:
"""Get existing genre or create a new one."""
genre = db.session.execute(
db.select(Genre).filter(Genre.name == name)
).scalar_one_or_none()
if genre is None:
genre = Genre(name=name)
db.session.add(genre)
# Don't commit here - let the caller handle the transaction
return genre
def start_reading(
book_id: int, user_id: int, start_date: date | None = None
) -> Reading:
"""Start a new reading session."""
# Check if book exists
book = db.session.get(Book, book_id)
if not book:
raise ValueError(f"Book not found: {book_id}")
# Check if user exists
user = db.session.get(User, user_id)
if not user:
raise ValueError(f"User not found: {user_id}")
# Check if already reading this book
existing_reading = db.session.execute(
db.select(Reading).filter(
and_(
Reading.book_id == book_id,
Reading.user_id == user_id,
Reading.end_date.is_(None), # Not finished yet
)
)
).scalar_one_or_none()
if existing_reading:
raise ValueError(
f"Already reading this book (reading session {existing_reading.id})"
)
reading = Reading(
book_id=book_id,
user_id=user_id,
start_date=start_date or datetime.now().date(),
)
db.session.add(reading)
db.session.commit()
return reading
def finish_reading(
reading_id: int,
rating: int | None = None,
comments: str | None = None,
end_date: date | None = None,
) -> Reading:
"""Finish a reading session."""
reading = db.session.execute(
db
.select(Reading)
.options(joinedload(Reading.book))
.filter(Reading.id == reading_id)
).scalar_one_or_none()
if not reading:
raise ValueError(f"Reading session not found: {reading_id}")
if reading.end_date is not None:
raise ValueError(f"Reading session {reading_id} is already finished")
reading.end_date = end_date or datetime.now().date()
reading.finished = True
reading.dropped = False
if rating is not None:
if not (1 <= rating <= 5):
raise ValueError("Rating must be between 1 and 5")
reading.rating = rating
if comments is not None:
reading.comments = comments
db.session.commit()
return reading
def drop_reading(
reading_id: int,
comments: str | None = None,
end_date: date | None = None,
) -> Reading:
"""Mark a reading session as dropped."""
reading = db.session.execute(
db
.select(Reading)
.options(joinedload(Reading.book))
.filter(Reading.id == reading_id)
).scalar_one_or_none()
if not reading:
raise ValueError(f"Reading session not found: {reading_id}")
if reading.end_date is not None:
raise ValueError(f"Reading session {reading_id} is already finished")
reading.end_date = end_date or datetime.now().date()
reading.finished = False
reading.dropped = True
if comments is not None:
reading.comments = comments
db.session.commit()
return reading
def get_current_readings(user_id: int) -> Sequence[Reading]:
"""Get all current (unfinished) readings for a user."""
return (
db.session
.execute(
db
.select(Reading)
.options(joinedload(Reading.book).joinedload(Book.authors))
.filter(
and_(
Reading.user_id == user_id,
Reading.end_date.is_(None),
)
)
.order_by(Reading.start_date.desc())
)
.scalars()
.unique()
.all()
)
def get_reading_history(user_id: int, limit: int = 50) -> Sequence[Reading]:
"""Get reading history for a user."""
return (
db.session
.execute(
db
.select(Reading)
.options(joinedload(Reading.book).joinedload(Book.authors))
.filter(Reading.user_id == user_id)
.order_by(Reading.start_date.desc())
.limit(limit)
)
.scalars()
.unique()
.all()
)
def add_to_wishlist(book_id: int, user_id: int) -> Wishlist:
"""Add a book to user's wishlist."""
# Check if book exists
book = db.session.get(Book, book_id)
if not book:
raise ValueError(f"Book not found: {book_id}")
# Check if user exists
user = db.session.get(User, user_id)
if not user:
raise ValueError(f"User not found: {user_id}")
# Check if already in wishlist
existing = db.session.execute(
db.select(Wishlist).filter(
and_(
Wishlist.book_id == book_id,
Wishlist.user_id == user_id,
)
)
).scalar_one_or_none()
if existing:
raise ValueError("Book is already in wishlist")
wishlist_item = Wishlist(
book_id=book_id,
user_id=user_id,
)
db.session.add(wishlist_item)
db.session.commit()
return wishlist_item
def remove_from_wishlist(book_id: int, user_id: int) -> bool:
"""Remove a book from user's wishlist."""
wishlist_item = db.session.execute(
db.select(Wishlist).filter(
and_(
Wishlist.book_id == book_id,
Wishlist.user_id == user_id,
)
)
).scalar_one_or_none()
if not wishlist_item:
return False
db.session.delete(wishlist_item)
db.session.commit()
return True
def get_wishlist(user_id: int) -> Sequence[Wishlist]:
"""Get user's wishlist."""
return (
db.session
.execute(
db
.select(Wishlist)
.options(joinedload(Wishlist.book).joinedload(Book.authors))
.filter(Wishlist.user_id == user_id)
.order_by(Wishlist.wishlisted_date.desc())
)
.scalars()
.unique()
.all()
)
def create_user(username: str) -> User:
"""Create a new user."""
# Check if username already exists
existing = db.session.execute(
db.select(User).filter(User.username == username)
).scalar_one_or_none()
if existing:
raise ValueError(f"Username '{username}' already exists")
user = User(username=username)
db.session.add(user)
db.session.commit()
return user
def get_user_by_username(username: str) -> User | None:
"""Get a user by username."""
return db.session.execute(
db.select(User).filter(User.username == username)
).scalar_one_or_none()
def list_users() -> Sequence[User]:
"""List all users."""
return db.session.execute(db.select(User).order_by(User.username)).scalars().all()

View File

@@ -1,65 +1,110 @@
from datetime import date, datetime
from typing import Optional
from sqlalchemy import JSON, ForeignKey
from sqlalchemy import JSON, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import db
class User(db.Model): # type: ignore[name-defined]
class User(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column()
saved_searches: Mapped[dict] = mapped_column(JSON, default=dict)
readings: Mapped[list["Reading"]] = relationship(back_populates="user")
owned_books: Mapped[list["Book"]] = relationship(back_populates="owner")
wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user")
readings: Mapped[list[Reading]] = relationship(back_populates="user")
owned_books: Mapped[list[Book]] = relationship(back_populates="owner")
wishes: Mapped[list[Wishlist]] = relationship(back_populates="user")
class Book(db.Model): # type: ignore[name-defined]
class Author(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(default="")
name: Mapped[str] = mapped_column(String(200))
books: Mapped[list[Book]] = relationship(
secondary="book_author", back_populates="authors"
)
class Genre(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
books: Mapped[list[Book]] = relationship(
secondary="book_genre", back_populates="genres"
)
class BookAuthor(db.Model): # ty:ignore[unsupported-base]
__tablename__ = "book_author"
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), primary_key=True)
class BookGenre(db.Model): # ty:ignore[unsupported-base]
__tablename__ = "book_genre"
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
genre_id: Mapped[int] = mapped_column(ForeignKey("genre.id"), primary_key=True)
class Book(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(500), default="")
description: Mapped[str] = mapped_column(default="")
first_published: Mapped[Optional[int]] = mapped_column(default=None)
edition: Mapped[str] = mapped_column(default="")
added: Mapped[datetime] = mapped_column(default=datetime.now)
first_published: Mapped[int | None] = mapped_column(default=None)
edition: Mapped[str] = mapped_column(String(200), default="")
publisher: Mapped[str] = mapped_column(String(200), default="")
isbn: Mapped[str] = mapped_column(String(20), default="")
notes: Mapped[str] = mapped_column(default="")
isbn: Mapped[str] = mapped_column(default="")
authors: Mapped[list[str]] = mapped_column(JSON, default=list)
genres: Mapped[list[str]] = mapped_column(JSON, default=list)
publisher: Mapped[str] = mapped_column(default="")
owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"))
bought: Mapped[date] = mapped_column(default=datetime.today)
location: Mapped[str] = mapped_column(default="billy salon")
loaned_to: Mapped[str] = mapped_column(default="")
loaned_from: Mapped[str] = mapped_column(default="")
owner: Mapped[Optional[User]] = relationship(back_populates="owned_books")
readings: Mapped[list["Reading"]] = relationship(
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
bought_date: Mapped[date | None] = mapped_column(default=None)
# Location hierarchy
location_place: Mapped[str] = mapped_column(String(100), default="")
location_bookshelf: Mapped[str] = mapped_column(String(100), default="")
location_shelf: Mapped[int | None] = mapped_column(default=None)
# Loaning
loaned_to: Mapped[str] = mapped_column(String(200), default="")
loaned_date: Mapped[date | None] = mapped_column(default=None)
# Relationships
owner_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
owner: Mapped[User | None] = relationship(back_populates="owned_books")
authors: Mapped[list[Author]] = relationship(
secondary="book_author", back_populates="books"
)
genres: Mapped[list[Genre]] = relationship(
secondary="book_genre", back_populates="books"
)
readings: Mapped[list[Reading]] = relationship(
back_populates="book", cascade="delete, delete-orphan"
)
wished_by: Mapped[list["Wishlist"]] = relationship(
wished_by: Mapped[list[Wishlist]] = relationship(
back_populates="book", cascade="delete, delete-orphan"
)
class Reading(db.Model): # type: ignore[name-defined]
class Reading(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
start_date: Mapped[date] = mapped_column(default=datetime.today)
end_date: Mapped[Optional[date]] = mapped_column(default=None)
start_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
end_date: Mapped[date | None] = mapped_column(default=None)
finished: Mapped[bool] = mapped_column(default=False)
dropped: Mapped[bool] = mapped_column(default=False)
rating: Mapped[Optional[int]] = mapped_column(default=None)
rating: Mapped[int | None] = mapped_column(default=None)
comments: Mapped[str] = mapped_column(default="")
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
user: Mapped["User"] = relationship(back_populates="readings")
book: Mapped["Book"] = relationship(back_populates="readings")
user: Mapped[User] = relationship(back_populates="readings")
book: Mapped[Book] = relationship(back_populates="readings")
class Wishlist(db.Model): # type: ignore[name-defined]
class Wishlist(db.Model): # ty:ignore[unsupported-base]
id: Mapped[int] = mapped_column(primary_key=True)
wishlisted: Mapped[date] = mapped_column(default=datetime.today)
wishlisted_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
user: Mapped["User"] = relationship(back_populates="wishes")
book: Mapped["Book"] = relationship(back_populates="wished_by")
user: Mapped[User] = relationship(back_populates="wishes")
book: Mapped[Book] = relationship(back_populates="wished_by")

225
src/hxbooks/search.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Search functionality for HXBooks.
Provides query parsing and search logic for finding books with advanced syntax.
Currently implements basic search - will be enhanced with pyparsing for advanced queries
"""
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import StrEnum
from typing import assert_never
import pyparsing as pp
class ComparisonOperator(StrEnum):
"""Supported comparison operators for search queries."""
EQUALS = "="
GREATER = ">"
GREATER_EQUAL = ">="
LESS = "<"
LESS_EQUAL = "<="
NOT_EQUALS = "!="
class Field(StrEnum):
"""Supported fields for field-specific searches."""
TITLE = "title"
AUTHOR = "author"
ISBN = "isbn"
GENRE = "genre"
YEAR = "year"
RATING = "rating"
PLACE = "place"
BOOKSHELF = "bookshelf"
SHELF = "shelf"
READ_DATE = "read"
BOUGHT_DATE = "bought"
ADDED_DATE = "added"
LOANED_DATE = "loaned"
OWNER = "owner"
IS = "is"
class IsOperatorValue(StrEnum):
"""Supported values for 'is' operator."""
LOANED = "loaned"
READ = "read"
READING = "reading"
DROPPED = "dropped"
WISHED = "wished"
UNKNOWN = "_unknown_"
ValueT = str | int | float | date | IsOperatorValue
@dataclass
class FieldFilter:
"""Represents a field-specific search filter."""
field: Field
operator: ComparisonOperator
value: ValueT
negated: bool = False
@dataclass
class SearchQuery:
"""Enhanced structured representation of a search query."""
text_terms: list[str] = field(default_factory=list)
field_filters: list[FieldFilter] = field(default_factory=list)
boolean_operator: str = "AND" # Default to AND for multiple terms
class QueryParser:
"""
Advanced query parser using pyparsing for sophisticated search syntax.
Supports:
- Field-specific searches: title:"The Hobbit" author:tolkien
- Date comparisons: read>=2025-01-01 bought<2024-12-31
- Numeric comparisons: rating>=4 shelf>2
- Boolean operators: genre:fantasy AND rating>=4
- Quoted strings: "science fiction"
- Negation: -genre:romance
- Parentheses: (genre:fantasy OR genre:scifi) AND rating>=4
"""
def __init__(self) -> None:
"""Initialize the pyparsing grammar."""
self._build_grammar()
def _build_grammar(self) -> None:
"""Build the pyparsing grammar for the query language."""
# Basic tokens
field_name = pp.Regex(r"[a-zA-Z_][a-zA-Z0-9_]*")
# Operators
comparison_op = pp.one_of(">= <= != > < =")
# Values
quoted_string = pp.QuotedString('"', esc_char="\\")
date_value = pp.Regex(r"\d{4}-\d{2}-\d{2}")
number_value = pp.Regex(r"\d+(?:\.\d+)?")
unquoted_word = pp.Regex(r'[^\s()"]+') # Any non-whitespace, non-special chars
value = quoted_string | date_value | number_value | unquoted_word
# Field filters: field:value or field>=value etc.
field_filter = pp.Group(
pp.Optional("-").set_results_name("negated")
+ field_name.set_results_name("field")
+ (comparison_op | ":").set_results_name("operator")
+ value.set_results_name("value")
)
# Free text terms (not field:value)
text_term = quoted_string | pp.Regex(r'[^\s():"]+(?![:\<\>=!])')
# Boolean operators
# and_op = pp.CaselessKeyword("AND")
# or_op = pp.CaselessKeyword("OR")
# not_op = pp.CaselessKeyword("NOT")
# Basic search element
search_element = field_filter | text_term
# For now, keep it simple - just parse field filters and text terms
# Full boolean logic can be added later if needed
query = pp.ZeroOrMore(search_element)
self.grammar = query
def parse(self, query_string: str) -> SearchQuery:
"""
Parse a search query string into structured components.
"""
if not query_string.strip():
return SearchQuery()
try:
parsed_elements = self.grammar.parse_string(query_string, parse_all=True)
except pp.ParseException:
# If parsing fails, fall back to simple text search
return SearchQuery(text_terms=[query_string])
text_terms = []
field_filters = []
for element in parsed_elements:
if (
isinstance(element, pp.ParseResults)
and "field" in element
and element["field"] in Field
):
# This is a field filter
field = Field(element["field"])
operator_str = element["operator"]
value_str = element["value"]
negated = bool(element.get("negated"))
# Convert operator string to enum
if operator_str in ComparisonOperator:
operator = ComparisonOperator(operator_str)
else:
operator = ComparisonOperator.EQUALS
# Convert value to appropriate type
value = _convert_value(field, value_str)
field_filters.append(
FieldFilter(
field=field, operator=operator, value=value, negated=negated
)
)
else:
# This is a text term
text_terms.append(str(element))
return SearchQuery(text_terms=text_terms, field_filters=field_filters)
def _convert_value(field: Field, value_str: str) -> ValueT:
"""Convert string value to appropriate type based on field."""
match field:
# Date fields
case Field.READ_DATE | Field.BOUGHT_DATE | Field.ADDED_DATE | Field.LOANED_DATE:
try:
return datetime.strptime(value_str, "%Y-%m-%d").date()
except ValueError:
return value_str
# Numeric fields
case Field.RATING | Field.SHELF | Field.YEAR:
try:
if "." in value_str:
return float(value_str)
else:
return int(value_str)
except ValueError:
return value_str
# String fields
case (
Field.OWNER
| Field.TITLE
| Field.AUTHOR
| Field.ISBN
| Field.GENRE
| Field.PLACE
| Field.BOOKSHELF
):
return value_str
case Field.IS:
if value_str in IsOperatorValue:
return IsOperatorValue(value_str)
else:
return IsOperatorValue.UNKNOWN
case _:
assert_never(field)

71
tests/conftest.py Normal file
View File

@@ -0,0 +1,71 @@
"""
Test configuration and fixtures for HXBooks.
Provides isolated test database, Flask app instances, and CLI testing utilities.
"""
from collections.abc import Generator
from pathlib import Path
import pytest
from click.testing import CliRunner
from flask import Flask
from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from hxbooks import cli
from hxbooks.app import create_app
from hxbooks.db import db
from hxbooks.models import User
@pytest.fixture
def app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Flask:
"""Create Flask app with test configuration."""
test_db_path = tmp_path / "test.db"
test_config = {
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{test_db_path}",
"SECRET_KEY": "test-secret-key",
"WTF_CSRF_ENABLED": False,
}
app = create_app(test_config)
with app.app_context():
db.create_all()
monkeypatch.setattr(cli, "get_app", lambda: app)
return app
@pytest.fixture
def client(app: Flask) -> FlaskClient:
"""Create test client for Flask app."""
return app.test_client()
@pytest.fixture
def cli_runner() -> CliRunner:
"""Create Click CLI test runner."""
return CliRunner()
@pytest.fixture
def test_user(app: Flask) -> User:
"""Create a test user in the database."""
with app.app_context():
user = User(username="testuser")
db.session.add(user)
db.session.commit()
# Refresh to get the ID
db.session.refresh(user)
return user
@pytest.fixture
def db_session(app: Flask) -> Generator[Session]:
"""Create database session for direct database testing."""
with app.app_context():
yield db.session

938
tests/test_cli.py Normal file
View File

@@ -0,0 +1,938 @@
"""
CLI command tests for HXBooks.
Tests all CLI commands for correct behavior, database integration, and output formatting.
"""
import json
import re
from datetime import date
import pytest
from click.testing import CliRunner
from flask import Flask
from hxbooks.cli import cli
from hxbooks.db import db
from hxbooks.models import Author, Book, Genre, Reading, User
class TestBookAddCommand:
"""Test the 'hxbooks book add' command."""
def test_book_add_basic(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test basic book addition with title and owner."""
# Run the CLI command
result = cli_runner.invoke(
cli,
[
"book",
"add",
"The Hobbit",
"--owner",
"frodo",
"--authors",
"J.R.R. Tolkien",
"--genres",
"Fantasy,Adventure",
"--isbn",
"9780547928227",
"--publisher",
"Houghton Mifflin Harcourt",
"--place",
"home",
"--bookshelf",
"living room",
"--shelf",
"2",
"--description",
"A classic fantasy tale",
"--notes",
"First edition",
],
)
# Verify CLI command succeeded
assert result.exit_code == 0, f"CLI command failed with output: {result.output}"
# Verify success message format
assert "Added book: The Hobbit (ID:" in result.output
assert "Created user: frodo" in result.output
# Verify database state
with app.app_context():
# Check user was created
users = db.session.execute(db.select(User)).scalars().all()
assert len(users) == 1
user = users[0]
assert user.username == "frodo"
# Check book was created with correct fields
books = (
db.session
.execute(db.select(Book).join(Book.authors).join(Book.genres))
.unique()
.scalars()
.all()
)
assert len(books) == 1
book = books[0]
assert book.title == "The Hobbit"
assert book.owner_id == user.id
assert book.isbn == "9780547928227"
assert book.publisher == "Houghton Mifflin Harcourt"
assert book.location_place == "home"
assert book.location_bookshelf == "living room"
assert book.location_shelf == 2
assert book.description == "A classic fantasy tale"
assert book.notes == "First edition"
# Check authors were created and linked
authors = db.session.execute(db.select(Author)).scalars().all()
assert len(authors) == 1
author = authors[0]
assert author.name == "J.R.R. Tolkien"
assert book in author.books
assert author in book.authors
# Check genres were created and linked
genres = db.session.execute(db.select(Genre)).scalars().all()
assert len(genres) == 2
genre_names = {genre.name for genre in genres}
assert genre_names == {"Fantasy", "Adventure"}
for genre in genres:
assert book in genre.books
assert genre in book.genres
def test_book_add_minimal_fields(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test book addition with only required fields."""
result = cli_runner.invoke(
cli, ["book", "add", "Minimal Book", "--owner", "alice"]
)
assert result.exit_code == 0
assert "Added book: Minimal Book (ID:" in result.output
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
assert book.title == "Minimal Book"
assert book.isbn == "" # Default empty string
assert book.publisher == ""
assert book.location_shelf is None # Default None
assert len(book.authors) == 0 # No authors provided
assert len(book.genres) == 0 # No genres provided
class TestBookListCommand:
"""Test the 'hxbooks book list' command."""
def test_book_list_empty(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing books when database is empty."""
result = cli_runner.invoke(cli, ["book", "list"])
assert result.exit_code == 0
assert "No books found." in result.output
def test_book_list_with_books(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing books in table format."""
# Add test data
cli_runner.invoke(
cli,
["book", "add", "Book One", "--owner", "alice", "--authors", "Author A"],
)
cli_runner.invoke(
cli, ["book", "add", "Book Two", "--owner", "bob", "--authors", "Author B"]
)
result = cli_runner.invoke(cli, ["book", "list"])
assert result.exit_code == 0
assert "Book One" in result.output
assert "Book Two" in result.output
assert "Author A" in result.output
assert "Author B" in result.output
assert "alice" in result.output
assert "bob" in result.output
def test_book_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing books in JSON format."""
# Add test data
cli_runner.invoke(
cli,
[
"book",
"add",
"Test Book",
"--owner",
"alice",
"--authors",
"Test Author",
"--isbn",
"1234567890",
],
)
result = cli_runner.invoke(cli, ["book", "list", "--format", "json"])
assert result.exit_code == 0
books_data = json.loads(result.output)
assert len(books_data) == 1
book = books_data[0]
assert book["title"] == "Test Book"
assert book["authors"] == ["Test Author"]
assert book["owner"] == "alice"
assert book["isbn"] == "1234567890"
def test_book_list_filter_by_owner(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test filtering books by owner."""
# Add books for different owners
cli_runner.invoke(cli, ["book", "add", "Alice Book", "--owner", "alice"])
cli_runner.invoke(cli, ["book", "add", "Bob Book", "--owner", "bob"])
result = cli_runner.invoke(cli, ["book", "list", "--owner", "alice"])
assert result.exit_code == 0
assert "Alice Book" in result.output
assert "Bob Book" not in result.output
def test_book_list_filter_by_location(
self, app: Flask, cli_runner: CliRunner
) -> None:
"""Test filtering books by location."""
# Add books in different locations
cli_runner.invoke(
cli,
[
"book",
"add",
"Home Book",
"--owner",
"alice",
"--place",
"home",
"--bookshelf",
"living",
"--shelf",
"1",
],
)
cli_runner.invoke(
cli,
[
"book",
"add",
"Office Book",
"--owner",
"alice",
"--place",
"office",
"--bookshelf",
"work",
"--shelf",
"2",
],
)
result = cli_runner.invoke(
cli,
[
"book",
"list",
"--place",
"home",
"--bookshelf",
"living",
"--shelf",
"1",
],
)
assert result.exit_code == 0
assert "Home Book" in result.output
assert "Office Book" not in result.output
class TestBookSearchCommand:
"""Test the 'hxbooks book search' command."""
def test_book_search_basic(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test basic book search functionality."""
# Add test books
cli_runner.invoke(
cli,
[
"book",
"add",
"The Hobbit",
"--owner",
"alice",
"--authors",
"Tolkien",
"--genres",
"Fantasy",
],
)
cli_runner.invoke(
cli,
[
"book",
"add",
"Dune",
"--owner",
"alice",
"--authors",
"Herbert",
"--genres",
"Sci-Fi",
],
)
result = cli_runner.invoke(cli, ["book", "search", "Hobbit"])
assert result.exit_code == 0
assert "The Hobbit" in result.output
assert "Dune" not in result.output
def test_book_search_no_results(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test search with no matching results."""
result = cli_runner.invoke(cli, ["book", "search", "nonexistent"])
assert result.exit_code == 0
assert "No books found." in result.output
def test_book_search_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test book search with JSON output."""
cli_runner.invoke(
cli,
[
"book",
"add",
"Test Book",
"--owner",
"alice",
"--authors",
"Test Author",
],
)
result = cli_runner.invoke(cli, ["book", "search", "Test", "--format", "json"])
assert result.exit_code == 0
search_results = json.loads(result.output)
assert len(search_results) >= 1
# Results format depends on BookService.search_books_advanced implementation
@pytest.mark.parametrize(
"query,username,expected_titles",
[
# String field filters
("title:Hobbit", "", ["The Hobbit"]),
("author:Tolkien", "", ["The Hobbit", "The Fellowship"]),
("genre:Fantasy", "", ["The Hobbit", "The Fellowship"]),
("owner:alice", "", ["The Hobbit", "The Fellowship", "Dune"]),
("place:home", "", ["The Hobbit", "Programming Book"]),
("bookshelf:fantasy", "", ["The Hobbit", "The Fellowship"]),
# Numeric field filters
("rating>=4", "", ["The Hobbit", "Programming Book"]),
("rating=3", "", ["Dune"]),
("shelf>1", "", ["The Fellowship", "Programming Book"]),
("year>=1954", "", ["The Fellowship", "Dune", "Programming Book"]),
# Date field filters
(
"added>=2026-03-15",
"",
["The Hobbit", "The Fellowship", "Dune", "Programming Book"],
),
("bought<2026-01-01", "", ["Programming Book"]),
# Negation
("-genre:Fantasy", "", ["Dune", "Programming Book"]),
("-owner:bob", "", ["The Hobbit", "The Fellowship", "Dune"]),
# Complex query with multiple filters
("-genre:Fantasy owner:alice", "", ["Dune"]),
# User-specific queries
("rating>=4", "alice", ["The Hobbit"]),
("is:reading", "alice", ["The Fellowship"]),
("is:read", "alice", ["The Hobbit", "Dune"]),
("is:wished", "alice", ["Programming Book"]),
],
)
def test_book_search_advanced_queries(
self,
app: Flask,
cli_runner: CliRunner,
query: str,
username: str,
expected_titles: list[str],
) -> None:
"""Test advanced search queries with various field filters."""
# Set up comprehensive test data
self._setup_search_test_data(app, cli_runner)
# Execute the search query
result = cli_runner.invoke(
cli,
["book", "search", "--format", "json", "--username", username, "--", query],
)
assert result.exit_code == 0, f"Search query '{query}' failed: {result.output}"
# Parse results and extract titles
search_results = json.loads(result.output)
actual_titles = [book["title"] for book in search_results]
# Verify expected titles are present (order doesn't matter)
assert set(expected_titles) == set(actual_titles), (
f"Query '{query}' expected {expected_titles}, got {actual_titles}"
)
def _setup_search_test_data(self, app: Flask, cli_runner: CliRunner) -> None:
"""Set up comprehensive test data for advanced search testing."""
# Book 1: The Hobbit - Fantasy, high rating, shelf 1, home
cli_runner.invoke(
cli,
[
"book",
"add",
"The Hobbit",
"--owner",
"alice",
"--authors",
"J.R.R. Tolkien",
"--genres",
"Fantasy,Adventure",
"--place",
"home",
"--bookshelf",
"fantasy",
"--shelf",
"1",
"--publisher",
"Allen & Unwin",
],
)
# Book 2: The Fellowship - Fantasy, high rating, shelf 2, office
cli_runner.invoke(
cli,
[
"book",
"add",
"The Fellowship",
"--owner",
"alice",
"--authors",
"J.R.R. Tolkien",
"--genres",
"Fantasy,Epic",
"--place",
"office",
"--bookshelf",
"fantasy",
"--shelf",
"2",
"--publisher",
"Allen & Unwin",
],
)
# Book 3: Dune - Sci-Fi, medium rating, shelf 1, office
cli_runner.invoke(
cli,
[
"book",
"add",
"Dune",
"--owner",
"alice",
"--authors",
"Frank Herbert",
"--genres",
"Science Fiction",
"--place",
"office",
"--bookshelf",
"scifi",
"--shelf",
"1",
"--publisher",
"Chilton Books",
],
)
# Book 4: Programming Book - Tech, high rating, shelf 2, home, different owner
cli_runner.invoke(
cli,
[
"book",
"add",
"Programming Book",
"--owner",
"bob",
"--authors",
"Tech Author",
"--genres",
"Technology,Programming",
"--place",
"home",
"--bookshelf",
"tech",
"--shelf",
"2",
"--publisher",
"Tech Press",
],
)
# Add some readings and ratings to test rating filters
with app.app_context():
# Get book IDs
books = (
db.session.execute(db.select(Book).order_by(Book.id)).scalars().all()
)
hobbit_id = next(b.id for b in books if b.title == "The Hobbit")
fellowship_id = next(b.id for b in books if b.title == "The Fellowship")
dune_id = next(b.id for b in books if b.title == "Dune")
prog_id = next(b.id for b in books if b.title == "Programming Book")
# Start and finish reading sessions with ratings
cli_runner.invoke(cli, ["reading", "start", str(hobbit_id), "--owner", "alice"])
cli_runner.invoke(cli, ["reading", "start", str(dune_id), "--owner", "alice"])
cli_runner.invoke(cli, ["reading", "start", str(prog_id), "--owner", "bob"])
cli_runner.invoke(
cli, ["reading", "start", str(fellowship_id), "--owner", "alice"]
)
with app.app_context():
# Get reading session IDs
readings = (
db.session
.execute(db.select(Reading).order_by(Reading.id))
.scalars()
.all()
)
hobbit_reading = next(r for r in readings if r.book_id == hobbit_id)
dune_reading = next(r for r in readings if r.book_id == dune_id)
prog_reading = next(r for r in readings if r.book_id == prog_id)
# Finish with different ratings
cli_runner.invoke(
cli, ["reading", "finish", str(hobbit_reading.id), "--rating", "5"]
)
cli_runner.invoke(
cli, ["reading", "finish", str(dune_reading.id), "--rating", "3"]
)
cli_runner.invoke(
cli, ["reading", "finish", str(prog_reading.id), "--rating", "4"]
)
# Update one book with bought_date for date filter testing
with app.app_context():
prog_book = db.session.get(Book, prog_id)
assert prog_book is not None
prog_book.bought_date = date(2025, 12, 1) # Before 2026-01-01
prog_book.first_published = 2000
hobbit_book = db.session.get(Book, hobbit_id)
assert hobbit_book is not None
hobbit_book.first_published = 1937
fellowship_book = db.session.get(Book, fellowship_id)
assert fellowship_book is not None
fellowship_book.first_published = 1954
dune_book = db.session.get(Book, dune_id)
assert dune_book is not None
dune_book.first_published = 1965
db.session.commit()
# Add a book to wishlist
cli_runner.invoke(
cli,
["wishlist", "add", str(prog_id), "--owner", "alice"],
)
class TestReadingCommands:
"""Test reading-related CLI commands."""
def test_reading_start_basic(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test starting a reading session."""
# Add a book first
result = cli_runner.invoke(
cli, ["book", "add", "Test Book", "--owner", "alice"]
)
assert result.exit_code == 0
# Extract book ID from output
book_id_match = re.search(r"ID: (\d+)", result.output)
assert book_id_match
book_id = book_id_match.group(1)
# Start reading session
result = cli_runner.invoke(
cli, ["reading", "start", book_id, "--owner", "alice"]
)
assert result.exit_code == 0
assert "Started reading session" in result.output
assert f"for book {book_id}" in result.output
def test_reading_finish_with_rating(
self, app: Flask, cli_runner: CliRunner
) -> None:
"""Test finishing a reading session with rating."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "Test Book", "--owner", "alice"])
with app.app_context():
# Get the book ID from database
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["reading", "start", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
# Extract reading session ID
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
assert reading_id_match
reading_id = reading_id_match.group(1)
# Finish reading with rating
result = cli_runner.invoke(
cli,
[
"reading",
"finish",
reading_id,
"--rating",
"4",
"--comments",
"Great book!",
],
)
assert result.exit_code == 0
assert "Finished reading: Test Book" in result.output
assert "Rating: 4/5" in result.output
def test_reading_drop(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test dropping a reading session."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "Boring Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["reading", "start", str(book_id), "--owner", "alice"]
)
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
assert reading_id_match is not None
reading_id = reading_id_match.group(1)
# Drop the reading
result = cli_runner.invoke(
cli, ["reading", "drop", reading_id, "--comments", "Too boring"]
)
assert result.exit_code == 0
assert "Dropped reading: Boring Book" in result.output
def test_reading_list_current(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing current (unfinished) readings."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "Current Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["reading", "list", "--owner", "alice", "--current"]
)
assert result.exit_code == 0, f"CLI command failed with output: {result.output}"
assert "Current Book" in result.output
assert "Reading" in result.output
def test_reading_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing readings in JSON format."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "JSON Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["reading", "list", "--owner", "alice", "--format", "json"]
)
assert result.exit_code == 0
readings_data = json.loads(result.output)
assert len(readings_data) == 1
reading = readings_data[0]
assert reading["book_title"] == "JSON Book"
assert reading["finished"] is False
class TestWishlistCommands:
"""Test wishlist-related CLI commands."""
def test_wishlist_add(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test adding a book to wishlist."""
# Add a book first
cli_runner.invoke(cli, ["book", "add", "Desired Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["wishlist", "add", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
assert "Added 'Desired Book' to wishlist" in result.output
def test_wishlist_remove(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test removing a book from wishlist."""
# Add book and add to wishlist
cli_runner.invoke(cli, ["book", "add", "Unwanted Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["wishlist", "remove", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
assert f"Removed book {book_id} from wishlist" in result.output
def test_wishlist_remove_not_in_list(
self, app: Flask, cli_runner: CliRunner
) -> None:
"""Test removing a book that's not in wishlist."""
# Add book but don't add to wishlist
cli_runner.invoke(cli, ["book", "add", "Not Wished Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["wishlist", "remove", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
assert f"Book {book_id} was not in wishlist" in result.output
def test_wishlist_list_empty(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing empty wishlist."""
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
assert result.exit_code == 0
assert "Wishlist is empty." in result.output
def test_wishlist_list_with_items(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing wishlist with items."""
# Add books and add to wishlist
cli_runner.invoke(
cli,
[
"book",
"add",
"Wished Book 1",
"--owner",
"alice",
"--authors",
"Author One",
],
)
cli_runner.invoke(
cli,
[
"book",
"add",
"Wished Book 2",
"--owner",
"alice",
"--authors",
"Author Two",
],
)
with app.app_context():
books = db.session.execute(db.select(Book)).scalars().all()
for book in books:
result = cli_runner.invoke(
cli, ["wishlist", "add", str(book.id), "--owner", "alice"]
)
assert result.exit_code == 0
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
assert result.exit_code == 0
assert "Wished Book 1" in result.output
assert "Wished Book 2" in result.output
assert "Author One" in result.output
assert "Author Two" in result.output
def test_wishlist_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing wishlist in JSON format."""
cli_runner.invoke(
cli,
[
"book",
"add",
"JSON Wished Book",
"--owner",
"alice",
"--authors",
"JSON Author",
],
)
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["wishlist", "list", "--owner", "alice", "--format", "json"]
)
assert result.exit_code == 0
wishlist_data = json.loads(result.output)
assert len(wishlist_data) == 1
item = wishlist_data[0]
assert item["title"] == "JSON Wished Book"
assert item["authors"] == ["JSON Author"]
class TestDatabaseCommands:
"""Test database management CLI commands."""
def test_db_init(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test database initialization."""
result = cli_runner.invoke(cli, ["db", "init"])
assert result.exit_code == 0
assert "Database initialized." in result.output
def test_db_seed(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test database seeding with sample data."""
result = cli_runner.invoke(cli, ["db", "seed", "--owner", "test_owner"])
assert result.exit_code == 0
assert "Created: The Hobbit" in result.output
assert "Created: Dune" in result.output
assert "Created: The Pragmatic Programmer" in result.output
assert "Created 3 sample books for user 'test_owner'" in result.output
# Verify books were actually created
with app.app_context():
books = db.session.execute(db.select(Book)).scalars().all()
assert len(books) == 3
titles = {book.title for book in books}
assert "The Hobbit" in titles
assert "Dune" in titles
assert "The Pragmatic Programmer" in titles
def test_db_status_empty(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test database status with empty database."""
result = cli_runner.invoke(cli, ["db", "status"])
assert result.exit_code == 0
assert "Database Statistics:" in result.output
assert "Books: 0" in result.output
assert "Authors: 0" in result.output
assert "Genres: 0" in result.output
assert "Users: 0" in result.output
assert "Reading sessions: 0" in result.output
assert "Wishlist items: 0" in result.output
def test_db_status_with_data(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test database status with sample data."""
# Add some test data
cli_runner.invoke(
cli,
[
"book",
"add",
"Status Book",
"--owner",
"alice",
"--authors",
"Status Author",
"--genres",
"Status Genre",
],
)
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
# Add reading and wishlist entries
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "bob"])
result = cli_runner.invoke(cli, ["db", "status"])
assert result.exit_code == 0
assert "Books: 1" in result.output
assert "Authors: 1" in result.output
assert "Genres: 1" in result.output
assert "Users: 2" in result.output # alice and bob
assert "Reading sessions: 1" in result.output
assert "Wishlist items: 1" in result.output
class TestErrorScenarios:
"""Test error handling and edge cases."""
def test_reading_start_invalid_book_id(
self, app: Flask, cli_runner: CliRunner
) -> None:
"""Test starting reading with non-existent book ID."""
result = cli_runner.invoke(cli, ["reading", "start", "999", "--owner", "alice"])
assert result.exit_code == 1
assert "Error starting reading:" in result.output
def test_wishlist_add_invalid_book_id(
self, app: Flask, cli_runner: CliRunner
) -> None:
"""Test adding non-existent book to wishlist."""
result = cli_runner.invoke(cli, ["wishlist", "add", "999", "--owner", "alice"])
assert result.exit_code == 1
assert "Error adding to wishlist:" in result.output
def test_reading_finish_invalid_reading_id(
self, app: Flask, cli_runner: CliRunner
) -> None:
"""Test finishing non-existent reading session."""
result = cli_runner.invoke(cli, ["reading", "finish", "999"])
assert result.exit_code == 1
assert "Error finishing reading:" in result.output

433
tests/test_search.py Normal file
View File

@@ -0,0 +1,433 @@
"""
Query parser tests for HXBooks search functionality.
Tests the QueryParser class methods for type conversion, operator parsing,
field filters, and edge case handling.
"""
from datetime import date
import pytest
from hxbooks.search import (
ComparisonOperator,
Field,
IsOperatorValue,
QueryParser,
SearchQuery,
_convert_value, # noqa: PLC2701
)
@pytest.fixture
def parser() -> QueryParser:
"""Create a QueryParser instance for testing."""
return QueryParser()
class TestQueryParser:
"""Test the QueryParser class functionality."""
def test_parse_empty_query(self, parser: QueryParser) -> None:
"""Test parsing an empty query string."""
result = parser.parse("")
assert result.text_terms == []
assert result.field_filters == []
def test_parse_whitespace_only(self, parser: QueryParser) -> None:
"""Test parsing a query with only whitespace."""
result = parser.parse(" \t\n ")
assert result.text_terms == []
assert result.field_filters == []
def test_parse_simple_text_terms(self, parser: QueryParser) -> None:
"""Test parsing simple text search terms."""
result = parser.parse("hobbit tolkien")
assert result.text_terms == ["hobbit", "tolkien"]
assert result.field_filters == []
def test_parse_quoted_text_terms(self, parser: QueryParser) -> None:
"""Test parsing quoted text search terms."""
result = parser.parse('"the hobbit" tolkien')
assert result.text_terms == ["the hobbit", "tolkien"]
assert result.field_filters == []
def test_parse_quoted_text_with_spaces(self, parser: QueryParser) -> None:
"""Test parsing quoted text containing multiple spaces."""
result = parser.parse('"lord of the rings"')
assert result.text_terms == ["lord of the rings"]
assert result.field_filters == []
class TestFieldFilters:
"""Test field filter parsing."""
def test_parse_title_filter(self, parser: QueryParser) -> None:
"""Test parsing title field filter."""
result = parser.parse("title:hobbit")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.TITLE
assert filter.operator == ComparisonOperator.EQUALS
assert filter.value == "hobbit"
assert filter.negated is False
def test_parse_quoted_title_filter(self, parser: QueryParser) -> None:
"""Test parsing quoted title field filter."""
result = parser.parse('title:"the hobbit"')
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.TITLE
assert filter.value == "the hobbit"
def test_parse_author_filter(self, parser: QueryParser) -> None:
"""Test parsing author field filter."""
result = parser.parse("author:tolkien")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.AUTHOR
assert filter.value == "tolkien"
def test_parse_is_filter(self, parser: QueryParser) -> None:
"""Test parsing 'is' operator field filter."""
result = parser.parse("is:reading")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.IS
assert filter.value == IsOperatorValue.READING
def test_parse_negated_filter(self, parser: QueryParser) -> None:
"""Test parsing negated field filter."""
result = parser.parse("-genre:romance")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.GENRE
assert filter.value == "romance"
assert filter.negated is True
def test_parse_multiple_filters(self, parser: QueryParser) -> None:
"""Test parsing multiple field filters."""
result = parser.parse("author:tolkien genre:fantasy")
assert len(result.field_filters) == 2
author_filter = next(f for f in result.field_filters if f.field == Field.AUTHOR)
assert author_filter.value == "tolkien"
genre_filter = next(f for f in result.field_filters if f.field == Field.GENRE)
assert genre_filter.value == "fantasy"
def test_parse_mixed_filters_and_text(self, parser: QueryParser) -> None:
"""Test parsing mix of field filters and text terms."""
result = parser.parse('epic author:tolkien "middle earth"')
assert "epic" in result.text_terms
assert "middle earth" in result.text_terms
assert len(result.field_filters) == 1
assert result.field_filters[0].field == Field.AUTHOR
class TestComparisonOperators:
"""Test comparison operator parsing."""
@pytest.mark.parametrize(
"operator_str,expected_operator",
[
(">=", ComparisonOperator.GREATER_EQUAL),
("<=", ComparisonOperator.LESS_EQUAL),
(">", ComparisonOperator.GREATER),
("<", ComparisonOperator.LESS),
("=", ComparisonOperator.EQUALS),
("!=", ComparisonOperator.NOT_EQUALS),
(":", ComparisonOperator.EQUALS), # : defaults to equals
],
)
def test_parse_comparison_operators(
self,
parser: QueryParser,
operator_str: str,
expected_operator: ComparisonOperator,
) -> None:
"""Test parsing all supported comparison operators."""
query = f"rating{operator_str}4"
result = parser.parse(query)
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.RATING
assert filter.operator == expected_operator
assert filter.value == 4
def test_parse_date_comparison(self, parser: QueryParser) -> None:
"""Test parsing date comparison operators."""
result = parser.parse("added>=2026-03-15")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.ADDED_DATE
assert filter.operator == ComparisonOperator.GREATER_EQUAL
assert filter.value == date(2026, 3, 15)
def test_parse_numeric_comparison(self, parser: QueryParser) -> None:
"""Test parsing numeric comparison operators."""
result = parser.parse("shelf>2")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.SHELF
assert filter.operator == ComparisonOperator.GREATER
assert filter.value == 2
class TestTypeConversion:
"""Test the _convert_value method for different field types."""
def test_convert_date_field_valid(self, parser: QueryParser) -> None:
"""Test converting valid date strings for date fields."""
result = _convert_value(Field.BOUGHT_DATE, "2026-03-15")
assert result == date(2026, 3, 15)
result = _convert_value(Field.READ_DATE, "2025-12-31")
assert result == date(2025, 12, 31)
result = _convert_value(Field.ADDED_DATE, "2024-01-01")
assert result == date(2024, 1, 1)
def test_convert_date_field_invalid(self, parser: QueryParser) -> None:
"""Test converting invalid date strings falls back to string."""
result = _convert_value(Field.BOUGHT_DATE, "invalid-date")
assert result == "invalid-date"
result = _convert_value(Field.READ_DATE, "2026-13-45") # Invalid month/day
assert result == "2026-13-45"
result = _convert_value(Field.ADDED_DATE, "not-a-date")
assert result == "not-a-date"
def test_convert_numeric_field_integers(self, parser: QueryParser) -> None:
"""Test converting integer strings for numeric fields."""
result = _convert_value(Field.RATING, "5")
assert result == 5
assert isinstance(result, int)
result = _convert_value(Field.SHELF, "10")
assert result == 10
result = _convert_value(Field.YEAR, "2026")
assert result == 2026
def test_convert_numeric_field_floats(self, parser: QueryParser) -> None:
"""Test converting float strings for numeric fields."""
result = _convert_value(Field.RATING, "4.5")
assert result == pytest.approx(4.5)
assert isinstance(result, float)
result = _convert_value(Field.SHELF, "2.0")
assert result == pytest.approx(2.0)
def test_convert_numeric_field_invalid(self, parser: QueryParser) -> None:
"""Test converting invalid numeric strings falls back to string."""
result = _convert_value(Field.RATING, "not-a-number")
assert result == "not-a-number"
result = _convert_value(Field.SHELF, "abc")
assert result == "abc"
result = _convert_value(Field.YEAR, "twenty-twenty-six")
assert result == "twenty-twenty-six"
def test_convert_string_fields(self, parser: QueryParser) -> None:
"""Test converting values for string fields returns as-is."""
result = _convert_value(Field.TITLE, "The Hobbit")
assert result == "The Hobbit"
result = _convert_value(Field.AUTHOR, "Tolkien")
assert result == "Tolkien"
result = _convert_value(Field.GENRE, "Fantasy")
assert result == "Fantasy"
# Even things that look like dates/numbers should stay as strings for string fields
result = _convert_value(Field.TITLE, "2026-03-15")
assert result == "2026-03-15"
assert isinstance(result, str)
result = _convert_value(Field.AUTHOR, "123")
assert result == "123"
assert isinstance(result, str)
def test_convert_is_operator(self, parser: QueryParser) -> None:
"""Test converting values for 'is' operator fields."""
result = _convert_value(Field.IS, "reading")
assert result == IsOperatorValue.READING
result = _convert_value(Field.IS, "dropped")
assert result == IsOperatorValue.DROPPED
result = _convert_value(Field.IS, "wished")
assert result == IsOperatorValue.WISHED
# Invalid value should return UNKNOWN
result = _convert_value(Field.IS, "invalid-status")
assert result == IsOperatorValue.UNKNOWN
class TestParsingEdgeCases:
"""Test edge cases and error handling in query parsing."""
def test_parse_invalid_field_name(self, parser: QueryParser) -> None:
"""Test parsing with invalid field names falls back to text search."""
result = parser.parse("invalid_field:value")
# Should fall back to treating the whole thing as text
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
def test_parse_mixed_quotes_and_operators(self, parser: QueryParser) -> None:
"""Test parsing complex queries with quotes and operators."""
result = parser.parse('title:"The Lord" author:tolkien rating>=4')
# Should have both field filters
title_filter = next(
(f for f in result.field_filters if f.field == Field.TITLE), None
)
author_filter = next(
(f for f in result.field_filters if f.field == Field.AUTHOR), None
)
rating_filter = next(
(f for f in result.field_filters if f.field == Field.RATING), None
)
assert title_filter is not None
assert title_filter.value == "The Lord"
assert author_filter is not None
assert author_filter.value == "tolkien"
assert rating_filter is not None
assert rating_filter.value == 4
assert rating_filter.operator == ComparisonOperator.GREATER_EQUAL
def test_parse_escaped_quotes(self, parser: QueryParser) -> None:
"""Test parsing strings with escaped quotes."""
result = parser.parse(r'title:"She said \"hello\""')
if result.field_filters:
# If parsing succeeds, check the escaped quote handling
filter = result.field_filters[0]
assert isinstance(filter.value, str)
assert "hello" in filter.value
# If parsing fails, it should fall back gracefully
def test_parse_special_characters(self, parser: QueryParser) -> None:
"""Test parsing queries with special characters."""
result = parser.parse("title:C++ author:Stroustrup")
# Should handle the + characters gracefully
assert len(result.field_filters) >= 1 or len(result.text_terms) >= 1
def test_parse_very_long_query(self, parser: QueryParser) -> None:
"""Test parsing very long query strings."""
long_value = "a" * 1000
result = parser.parse(f"title:{long_value}")
# Should handle long strings without crashing
assert isinstance(result, SearchQuery)
def test_parse_unicode_characters(self, parser: QueryParser) -> None:
"""Test parsing queries with unicode characters."""
result = parser.parse("title:Café author:José")
# Should handle unicode gracefully
assert isinstance(result, SearchQuery)
def test_fallback_behavior_on_parse_error(self, parser: QueryParser) -> None:
"""Test that invalid syntax falls back to text search."""
# Construct a query that should cause parse errors
invalid_queries = [
"(((", # Unmatched parentheses
"field::", # Double colon
":", # Just a colon
">=<=", # Invalid operator combination
]
for query in invalid_queries:
result = parser.parse(query)
# Should not crash and should return some kind of result
assert isinstance(result, SearchQuery)
# Most likely falls back to text terms
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
class TestComplexQueries:
"""Test parsing of complex, real-world query examples."""
def test_parse_realistic_book_search(self, parser: QueryParser) -> None:
"""Test parsing realistic book search queries."""
result = parser.parse(
'author:tolkien genre:fantasy -genre:romance rating>=4 "middle earth"'
)
# Should have multiple field filters and text terms
assert len(result.field_filters) >= 3
assert "middle earth" in result.text_terms
# Check specific filters
tolkien_filter = next(
(f for f in result.field_filters if f.field == Field.AUTHOR), None
)
assert tolkien_filter is not None
assert tolkien_filter.value == "tolkien"
fantasy_filter = next(
(
f
for f in result.field_filters
if f.field == Field.GENRE and not f.negated
),
None,
)
assert fantasy_filter is not None
assert fantasy_filter.value == "fantasy"
romance_filter = next(
(f for f in result.field_filters if f.field == Field.GENRE and f.negated),
None,
)
assert romance_filter is not None
assert romance_filter.value == "romance"
assert romance_filter.negated is True
def test_parse_location_and_date_filters(self, parser: QueryParser) -> None:
"""Test parsing location and date-based queries."""
result = parser.parse("place:home bookshelf:fantasy shelf>=2 added>=2026-01-01")
assert len(result.field_filters) == 4
place_filter = next(
(f for f in result.field_filters if f.field == Field.PLACE), None
)
assert place_filter is not None
assert place_filter.value == "home"
shelf_filter = next(
(f for f in result.field_filters if f.field == Field.SHELF), None
)
assert shelf_filter is not None
assert shelf_filter.value == 2
assert shelf_filter.operator == ComparisonOperator.GREATER_EQUAL
added_filter = next(
(f for f in result.field_filters if f.field == Field.ADDED_DATE), None
)
assert added_filter is not None
assert added_filter.value == date(2026, 1, 1)
assert added_filter.operator == ComparisonOperator.GREATER_EQUAL
def test_parse_mixed_types_comprehensive(self, parser: QueryParser) -> None:
"""Test parsing query with all major field types."""
query = 'title:"Complex Book" author:Author year=2020 rating>=4 bought<=2025-12-31 -genre:boring epic adventure'
result = parser.parse(query)
# Should have a good mix of field filters and text terms
assert len(result.field_filters) >= 5
assert len(result.text_terms) >= 2
# Verify we got the expected mix of string, numeric, and date fields
field_types = {f.field for f in result.field_filters}
assert Field.TITLE in field_types
assert Field.AUTHOR in field_types
assert Field.YEAR in field_types
assert Field.RATING in field_types
assert Field.BOUGHT_DATE in field_types
assert Field.GENRE in field_types

687
uv.lock generated Normal file
View File

@@ -0,0 +1,687 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[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, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[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, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.25.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-htmx"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/b7/1ba8b722ccc12b72b44af949f438a85111ba8db9e39f973dff4a47da068e/flask_htmx-0.4.0.tar.gz", hash = "sha256:2d367fb27c8da99d031a0c566b7e562637139722e2d4e8ec67c7f941addb22fd", size = 5815, upload-time = "2024-09-22T04:14:20.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/8e/7e75c2210567ba11df9ea7d031eb5b8f45e82f6112cc8be885cb0ce86c7d/flask_htmx-0.4.0-py3-none-any.whl", hash = "sha256:ac0ef976638bc635537a47c4ae622c91aef1e69d8bf52880aa9ae0db089ce7d2", size = 6773, upload-time = "2024-09-22T04:14:18.41Z" },
]
[[package]]
name = "flask-migrate"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "flask" },
{ name = "flask-sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload-time = "2025-01-10T18:51:11.848Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "hxbooks"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "click" },
{ name = "flask" },
{ name = "flask-htmx" },
{ name = "flask-migrate" },
{ name = "flask-sqlalchemy" },
{ name = "gunicorn" },
{ name = "jinja2-fragments" },
{ name = "pydantic" },
{ name = "pyparsing" },
{ name = "requests" },
{ name = "sqlalchemy" },
]
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
{ name = "click", specifier = ">=8.3.1" },
{ name = "flask", specifier = ">=3.1.3" },
{ name = "flask-htmx", specifier = ">=0.4.0" },
{ name = "flask-migrate", specifier = ">=4.0.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "gunicorn", specifier = ">=25.1.0" },
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pyparsing", specifier = ">=3.3.2" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "sqlalchemy", specifier = ">=2.0.48" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.5.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "ruff", specifier = ">=0.15.6" },
{ name = "ty", specifier = ">=0.0.23" },
]
[[package]]
name = "identify"
version = "2.6.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jinja2-fragments"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/06/51681ecdfe06a51c458da481f353bfc9325d56491fec2be138b63e93e2bb/jinja2_fragments-1.11.0.tar.gz", hash = "sha256:240eabb7faaa379110cf8e43acb81fb8731fd6ae39c7a1ae232e4421c4804248", size = 20980, upload-time = "2025-11-20T21:39:48.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/4d/b65f80e4aca3a630105f48192dac6ed16699e6d53197899840da2d67c3a5/jinja2_fragments-1.11.0-py3-none-any.whl", hash = "sha256:3b37105d565b96129e2e34df040d1b7bb71c8a76014f7b5e1aa914ccf3f9256c", size = 15999, upload-time = "2025-11-20T21:39:47.516Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-discovery"
version = "1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "ruff"
version = "0.15.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
{ url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
[[package]]
name = "ty"
version = "0.0.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" },
{ url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" },
{ url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" },
{ url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" },
{ url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" },
{ url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" },
{ url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" },
{ url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" },
{ url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" },
{ url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" },
{ url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" },
{ url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" },
{ url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" },
{ url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "virtualenv"
version = "21.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]