Compare commits
5 Commits
20265e679b
...
0083e3d896
| Author | SHA1 | Date | |
|---|---|---|---|
| 0083e3d896 | |||
| d427cec8d5 | |||
| 40ca08359f | |||
| c30ad57051 | |||
| 9232ac6133 |
103
.github/copilot-instructions.md
vendored
Normal file
103
.github/copilot-instructions.md
vendored
Normal 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
3
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
instance/
|
||||
instance/
|
||||
*.sqlite
|
||||
15
.pre-commit-config.yaml
Normal file
15
.pre-commit-config.yaml
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
247
docs/development-plan.md
Normal file
247
docs/development-plan.md
Normal 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
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
108
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
||||
143
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
143
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal 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 ###
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
48
src/hxbooks/app.py
Normal 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
|
||||
@@ -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
633
src/hxbooks/cli.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
726
src/hxbooks/library.py
Normal 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()
|
||||
@@ -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
225
src/hxbooks/search.py
Normal 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
71
tests/conftest.py
Normal 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
938
tests/test_cli.py
Normal 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
433
tests/test_search.py
Normal 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
687
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user