Linted and formatted everything new
This commit is contained in:
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
|
||||||
@@ -12,32 +12,31 @@ config = context.config
|
|||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
logger = logging.getLogger('alembic.env')
|
logger = logging.getLogger("alembic.env")
|
||||||
|
|
||||||
|
|
||||||
def get_engine():
|
def get_engine():
|
||||||
try:
|
try:
|
||||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
return current_app.extensions['migrate'].db.get_engine()
|
return current_app.extensions["migrate"].db.get_engine()
|
||||||
except (TypeError, AttributeError):
|
except TypeError, AttributeError:
|
||||||
# this works with Flask-SQLAlchemy>=3
|
# this works with Flask-SQLAlchemy>=3
|
||||||
return current_app.extensions['migrate'].db.engine
|
return current_app.extensions["migrate"].db.engine
|
||||||
|
|
||||||
|
|
||||||
def get_engine_url():
|
def get_engine_url():
|
||||||
try:
|
try:
|
||||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||||
'%', '%%')
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return str(get_engine().url).replace('%', '%%')
|
return str(get_engine().url).replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
# add your model's MetaData object here
|
||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
# from myapp import mymodel
|
# from myapp import mymodel
|
||||||
# target_metadata = mymodel.Base.metadata
|
# target_metadata = mymodel.Base.metadata
|
||||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||||
target_db = current_app.extensions['migrate'].db
|
target_db = current_app.extensions["migrate"].db
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
# can be acquired:
|
# can be acquired:
|
||||||
@@ -46,7 +45,7 @@ target_db = current_app.extensions['migrate'].db
|
|||||||
|
|
||||||
|
|
||||||
def get_metadata():
|
def get_metadata():
|
||||||
if hasattr(target_db, 'metadatas'):
|
if hasattr(target_db, "metadatas"):
|
||||||
return target_db.metadatas[None]
|
return target_db.metadatas[None]
|
||||||
return target_db.metadata
|
return target_db.metadata
|
||||||
|
|
||||||
@@ -64,9 +63,7 @@ def run_migrations_offline():
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
context.configure(
|
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
@@ -84,13 +81,13 @@ def run_migrations_online():
|
|||||||
# when there are no changes to the schema
|
# when there are no changes to the schema
|
||||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
def process_revision_directives(context, revision, directives):
|
def process_revision_directives(context, revision, directives):
|
||||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
if getattr(config.cmd_opts, "autogenerate", False):
|
||||||
script = directives[0]
|
script = directives[0]
|
||||||
if script.upgrade_ops.is_empty():
|
if script.upgrade_ops.is_empty():
|
||||||
directives[:] = []
|
directives[:] = []
|
||||||
logger.info('No changes in schema detected.')
|
logger.info("No changes in schema detected.")
|
||||||
|
|
||||||
conf_args = current_app.extensions['migrate'].configure_args
|
conf_args = current_app.extensions["migrate"].configure_args
|
||||||
if conf_args.get("process_revision_directives") is None:
|
if conf_args.get("process_revision_directives") is None:
|
||||||
conf_args["process_revision_directives"] = process_revision_directives
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
@@ -98,9 +95,7 @@ def run_migrations_online():
|
|||||||
|
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection, target_metadata=get_metadata(), **conf_args
|
||||||
target_metadata=get_metadata(),
|
|
||||||
**conf_args
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
"""Initial migration
|
"""Initial migration
|
||||||
|
|
||||||
Revision ID: 75e81e4ab7b6
|
Revision ID: 75e81e4ab7b6
|
||||||
Revises:
|
Revises:
|
||||||
Create Date: 2026-03-14 22:51:20.059755
|
Create Date: 2026-03-14 22:51:20.059755
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '75e81e4ab7b6'
|
revision = "75e81e4ab7b6"
|
||||||
down_revision = None
|
down_revision = None
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
@@ -18,90 +19,125 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('author',
|
op.create_table(
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"author",
|
||||||
sa.Column('name', sa.String(length=200), nullable=False),
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column("name", sa.String(length=200), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_table('genre',
|
op.create_table(
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"genre",
|
||||||
sa.Column('name', sa.String(length=100), nullable=False),
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column("name", sa.String(length=100), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_table('user',
|
op.create_table(
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"user",
|
||||||
sa.Column('username', sa.String(), nullable=False),
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
sa.Column('saved_searches', sa.JSON(), nullable=False),
|
sa.Column("username", sa.String(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column("saved_searches", sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_table('book',
|
op.create_table(
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"book",
|
||||||
sa.Column('title', sa.String(length=500), nullable=False),
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
sa.Column('description', sa.String(), nullable=False),
|
sa.Column("title", sa.String(length=500), nullable=False),
|
||||||
sa.Column('first_published', sa.Integer(), nullable=True),
|
sa.Column("description", sa.String(), nullable=False),
|
||||||
sa.Column('edition', sa.String(length=200), nullable=False),
|
sa.Column("first_published", sa.Integer(), nullable=True),
|
||||||
sa.Column('publisher', sa.String(length=200), nullable=False),
|
sa.Column("edition", sa.String(length=200), nullable=False),
|
||||||
sa.Column('isbn', sa.String(length=20), nullable=False),
|
sa.Column("publisher", sa.String(length=200), nullable=False),
|
||||||
sa.Column('notes', sa.String(), nullable=False),
|
sa.Column("isbn", sa.String(length=20), nullable=False),
|
||||||
sa.Column('added_date', sa.DateTime(), nullable=False),
|
sa.Column("notes", sa.String(), nullable=False),
|
||||||
sa.Column('bought_date', sa.Date(), nullable=True),
|
sa.Column("added_date", sa.DateTime(), nullable=False),
|
||||||
sa.Column('location_place', sa.String(length=100), nullable=False),
|
sa.Column("bought_date", sa.Date(), nullable=True),
|
||||||
sa.Column('location_bookshelf', sa.String(length=100), nullable=False),
|
sa.Column("location_place", sa.String(length=100), nullable=False),
|
||||||
sa.Column('location_shelf', sa.Integer(), nullable=True),
|
sa.Column("location_bookshelf", sa.String(length=100), nullable=False),
|
||||||
sa.Column('loaned_to', sa.String(length=200), nullable=False),
|
sa.Column("location_shelf", sa.Integer(), nullable=True),
|
||||||
sa.Column('loaned_date', sa.Date(), nullable=True),
|
sa.Column("loaned_to", sa.String(length=200), nullable=False),
|
||||||
sa.Column('owner_id', sa.Integer(), nullable=True),
|
sa.Column("loaned_date", sa.Date(), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
sa.Column("owner_id", sa.Integer(), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_id"],
|
||||||
|
["user.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_table('book_author',
|
op.create_table(
|
||||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
"book_author",
|
||||||
sa.Column('author_id', sa.Integer(), nullable=False),
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
|
sa.Column("author_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
sa.PrimaryKeyConstraint('book_id', 'author_id')
|
["author_id"],
|
||||||
|
["author.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("book_id", "author_id"),
|
||||||
)
|
)
|
||||||
op.create_table('book_genre',
|
op.create_table(
|
||||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
"book_genre",
|
||||||
sa.Column('genre_id', sa.Integer(), nullable=False),
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
sa.Column("genre_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
sa.PrimaryKeyConstraint('book_id', 'genre_id')
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["genre_id"],
|
||||||
|
["genre.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("book_id", "genre_id"),
|
||||||
)
|
)
|
||||||
op.create_table('reading',
|
op.create_table(
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"reading",
|
||||||
sa.Column('start_date', sa.Date(), nullable=False),
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
sa.Column('end_date', sa.Date(), nullable=True),
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
sa.Column('finished', sa.Boolean(), nullable=False),
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
sa.Column('dropped', sa.Boolean(), nullable=False),
|
sa.Column("finished", sa.Boolean(), nullable=False),
|
||||||
sa.Column('rating', sa.Integer(), nullable=True),
|
sa.Column("dropped", sa.Boolean(), nullable=False),
|
||||||
sa.Column('comments', sa.String(), nullable=False),
|
sa.Column("rating", sa.Integer(), nullable=True),
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
sa.Column("comments", sa.String(), nullable=False),
|
||||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
sa.PrimaryKeyConstraint('id')
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["user.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_table('wishlist',
|
op.create_table(
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
"wishlist",
|
||||||
sa.Column('wishlisted_date', sa.Date(), nullable=False),
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
sa.Column("wishlisted_date", sa.Date(), nullable=False),
|
||||||
sa.Column('book_id', sa.Integer(), nullable=False),
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
sa.PrimaryKeyConstraint('id')
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["user.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('wishlist')
|
op.drop_table("wishlist")
|
||||||
op.drop_table('reading')
|
op.drop_table("reading")
|
||||||
op.drop_table('book_genre')
|
op.drop_table("book_genre")
|
||||||
op.drop_table('book_author')
|
op.drop_table("book_author")
|
||||||
op.drop_table('book')
|
op.drop_table("book")
|
||||||
op.drop_table('user')
|
op.drop_table("user")
|
||||||
op.drop_table('genre')
|
op.drop_table("genre")
|
||||||
op.drop_table('author')
|
op.drop_table("author")
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ dependencies = [
|
|||||||
"jinja2-fragments>=1.11.0",
|
"jinja2-fragments>=1.11.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pyparsing>=3.3.2",
|
"pyparsing>=3.3.2",
|
||||||
"pytest>=9.0.2",
|
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"sqlalchemy>=2.0.48",
|
"sqlalchemy>=2.0.48",
|
||||||
]
|
]
|
||||||
@@ -27,5 +26,38 @@ hxbooks = "hxbooks.cli:cli"
|
|||||||
requires = ["uv_build>=0.10.10,<0.11.0"]
|
requires = ["uv_build>=0.10.10,<0.11.0"]
|
||||||
build-backend = "uv_build"
|
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]
|
[tool.pytest.ini_options]
|
||||||
addopts = ["-v", "--tb=short"]
|
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,54 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
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: Optional[dict] = 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 = Migrate(app, db.db)
|
|
||||||
|
|
||||||
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
|
import livereload # type: ignore
|
||||||
|
|
||||||
from hxbooks import create_app
|
from hxbooks.app import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.debug = True
|
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"):
|
class SearchRequestSchema(BaseModel, extra="forbid"):
|
||||||
q: str = ""
|
q: str = ""
|
||||||
wishlisted: Optional[bool] = None
|
wishlisted: bool | None = None
|
||||||
read: Optional[bool] = None
|
read: bool | None = None
|
||||||
reading: Optional[bool] = None
|
reading: bool | None = None
|
||||||
dropped: Optional[bool] = None
|
dropped: bool | None = None
|
||||||
bought_start: Optional[date] = None
|
bought_start: date | None = None
|
||||||
bought_end: Optional[date] = None
|
bought_end: date | None = None
|
||||||
started_reading_start: Optional[date] = None
|
started_reading_start: date | None = None
|
||||||
started_reading_end: Optional[date] = None
|
started_reading_end: date | None = None
|
||||||
finished_reading_start: Optional[date] = None
|
finished_reading_start: date | None = None
|
||||||
finished_reading_end: Optional[date] = None
|
finished_reading_end: date | None = None
|
||||||
sort_by: ResultColumn = "title"
|
sort_by: ResultColumn = "title"
|
||||||
sort_order: Literal["asc", "desc"] = "asc"
|
sort_order: Literal["asc", "desc"] = "asc"
|
||||||
saved_search: Optional[str] = None
|
saved_search: str | None = None
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"wishlisted",
|
"wishlisted",
|
||||||
@@ -104,13 +104,13 @@ class BookResultSchema(BaseModel):
|
|||||||
authors: list[str]
|
authors: list[str]
|
||||||
genres: list[str]
|
genres: list[str]
|
||||||
publisher: str
|
publisher: str
|
||||||
first_published: Optional[int]
|
first_published: int | None
|
||||||
edition: str
|
edition: str
|
||||||
added: datetime
|
added: datetime
|
||||||
description: str
|
description: str
|
||||||
notes: str
|
notes: str
|
||||||
isbn: str
|
isbn: str
|
||||||
owner: Optional[str]
|
owner: str | None
|
||||||
bought: date
|
bought: date
|
||||||
location: str
|
location: str
|
||||||
loaned_to: str
|
loaned_to: str
|
||||||
@@ -119,8 +119,8 @@ class BookResultSchema(BaseModel):
|
|||||||
read: bool
|
read: bool
|
||||||
reading: bool
|
reading: bool
|
||||||
dropped: bool
|
dropped: bool
|
||||||
started_reading: Optional[date]
|
started_reading: date | None
|
||||||
finished_reading: Optional[date]
|
finished_reading: date | None
|
||||||
|
|
||||||
|
|
||||||
@bp.route("", methods=["GET"])
|
@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]:
|
def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
|
||||||
searches = get_default_searches(user.username).copy()
|
searches = get_default_searches(user.username).copy()
|
||||||
searches.update(
|
searches.update({
|
||||||
{
|
name: SearchRequestSchema.model_validate(value)
|
||||||
name: SearchRequestSchema.model_validate(value)
|
for name, value in user.saved_searches.items()
|
||||||
for name, value in user.saved_searches.items()
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
for name, search in searches.items():
|
for name, search in searches.items():
|
||||||
search.saved_search = name
|
search.saved_search = name
|
||||||
return searches
|
return searches
|
||||||
@@ -390,14 +388,14 @@ def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
|
|||||||
|
|
||||||
class BookRequestSchema(BaseModel):
|
class BookRequestSchema(BaseModel):
|
||||||
title: str = Field(min_length=1)
|
title: str = Field(min_length=1)
|
||||||
first_published: Optional[int] = None
|
first_published: int | None = None
|
||||||
edition: str = ""
|
edition: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
isbn: str = ""
|
isbn: str = ""
|
||||||
authors: list[str] = []
|
authors: list[str] = []
|
||||||
genres: list[str] = []
|
genres: list[str] = []
|
||||||
publisher: str = ""
|
publisher: str = ""
|
||||||
owner_id: Optional[int] = None
|
owner_id: int | None = None
|
||||||
bought: date = Field(default_factory=datetime.today)
|
bought: date = Field(default_factory=datetime.today)
|
||||||
location: str = "billy salon"
|
location: str = "billy salon"
|
||||||
loaned_to: str = ""
|
loaned_to: str = ""
|
||||||
@@ -456,7 +454,8 @@ def book(id: int) -> str | Response:
|
|||||||
"users": db.session.execute(select(User)).scalars().all(),
|
"users": db.session.execute(select(User)).scalars().all(),
|
||||||
"genres": get_distinct_json_list_values(Book.genres),
|
"genres": get_distinct_json_list_values(Book.genres),
|
||||||
"authors": get_distinct_json_list_values(Book.authors),
|
"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()
|
.scalars()
|
||||||
.all(),
|
.all(),
|
||||||
"wished_by": [wishlist.user.username for wishlist in book.wished_by],
|
"wished_by": [wishlist.user.username for wishlist in book.wished_by],
|
||||||
@@ -481,10 +480,10 @@ def readings_new(id: int) -> str:
|
|||||||
|
|
||||||
class ReadingRequestSchema(BaseModel):
|
class ReadingRequestSchema(BaseModel):
|
||||||
start_date: date = Field(default_factory=datetime.today)
|
start_date: date = Field(default_factory=datetime.today)
|
||||||
end_date: Optional[date] = None
|
end_date: date | None = None
|
||||||
finished: bool = False
|
finished: bool = False
|
||||||
dropped: bool = False
|
dropped: bool = False
|
||||||
rating: Optional[int] = None
|
rating: int | None = None
|
||||||
comments: str = ""
|
comments: str = ""
|
||||||
user_id: int
|
user_id: int
|
||||||
book_id: int
|
book_id: int
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ while keeping business logic separate from web interface concerns.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from . import create_app
|
from . import library
|
||||||
from .services import BookService, ReadingService, WishlistService
|
from .app import create_app
|
||||||
|
from .db import db
|
||||||
|
from .models import Author, Book, Genre, Reading, User, Wishlist
|
||||||
|
|
||||||
|
|
||||||
def get_app() -> Flask:
|
def get_app() -> Flask:
|
||||||
@@ -24,8 +24,6 @@ def get_app() -> Flask:
|
|||||||
|
|
||||||
def ensure_user_exists(app: Flask, username: str) -> int:
|
def ensure_user_exists(app: Flask, username: str) -> int:
|
||||||
"""Ensure a user exists and return their ID."""
|
"""Ensure a user exists and return their ID."""
|
||||||
from .db import db
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
user = db.session.execute(
|
user = db.session.execute(
|
||||||
@@ -43,31 +41,31 @@ def ensure_user_exists(app: Flask, username: str) -> int:
|
|||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
@click.version_option()
|
@click.version_option()
|
||||||
def cli():
|
def cli() -> None:
|
||||||
"""HXBooks - Personal library management system."""
|
"""HXBooks - Personal library management system."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def book():
|
def book() -> None:
|
||||||
"""Book management commands."""
|
"""Book management commands."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def reading():
|
def reading() -> None:
|
||||||
"""Reading tracking commands."""
|
"""Reading tracking commands."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def wishlist():
|
def wishlist() -> None:
|
||||||
"""Wishlist management commands."""
|
"""Wishlist management commands."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group("db")
|
||||||
def db():
|
def db_group() -> None:
|
||||||
"""Database management commands."""
|
"""Database management commands."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -89,26 +87,24 @@ def db():
|
|||||||
def add_book(
|
def add_book(
|
||||||
title: str,
|
title: str,
|
||||||
owner: str,
|
owner: str,
|
||||||
authors: Optional[str] = None,
|
authors: str | None = None,
|
||||||
genres: Optional[str] = None,
|
genres: str | None = None,
|
||||||
isbn: Optional[str] = None,
|
isbn: str | None = None,
|
||||||
publisher: Optional[str] = None,
|
publisher: str | None = None,
|
||||||
edition: Optional[str] = None,
|
edition: str | None = None,
|
||||||
place: Optional[str] = None,
|
place: str | None = None,
|
||||||
bookshelf: Optional[str] = None,
|
bookshelf: str | None = None,
|
||||||
shelf: Optional[int] = None,
|
shelf: int | None = None,
|
||||||
description: Optional[str] = None,
|
description: str | None = None,
|
||||||
notes: Optional[str] = None,
|
notes: str | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""Add a new book to the library."""
|
"""Add a new book to the library."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = BookService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
book = service.create_book(
|
book = library.create_book(
|
||||||
title=title,
|
title=title,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
authors=authors.split(",") if authors else None,
|
authors=authors.split(",") if authors else None,
|
||||||
@@ -142,21 +138,19 @@ def add_book(
|
|||||||
)
|
)
|
||||||
@click.option("--limit", type=int, default=50, help="Maximum number of books to show")
|
@click.option("--limit", type=int, default=50, help="Maximum number of books to show")
|
||||||
def list_books(
|
def list_books(
|
||||||
owner: Optional[str] = None,
|
owner: str | None = None,
|
||||||
place: Optional[str] = None,
|
place: str | None = None,
|
||||||
bookshelf: Optional[str] = None,
|
bookshelf: str | None = None,
|
||||||
shelf: Optional[int] = None,
|
shelf: int | None = None,
|
||||||
output_format: str = "table",
|
output_format: str = "table",
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
):
|
) -> None:
|
||||||
"""List books in the library."""
|
"""List books in the library."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = BookService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
books = service.search_books(
|
books = library.search_books(
|
||||||
owner_username=owner,
|
owner_username=owner,
|
||||||
location_place=place,
|
location_place=place,
|
||||||
location_bookshelf=bookshelf,
|
location_bookshelf=bookshelf,
|
||||||
@@ -167,17 +161,15 @@ def list_books(
|
|||||||
if output_format == "json":
|
if output_format == "json":
|
||||||
book_data = []
|
book_data = []
|
||||||
for book in books:
|
for book in books:
|
||||||
book_data.append(
|
book_data.append({
|
||||||
{
|
"id": book.id,
|
||||||
"id": book.id,
|
"title": book.title,
|
||||||
"title": book.title,
|
"authors": [a.name for a in book.authors],
|
||||||
"authors": [a.name for a in book.authors],
|
"genres": [g.name for g in book.genres],
|
||||||
"genres": [g.name for g in book.genres],
|
"owner": book.owner.username if book.owner else None,
|
||||||
"owner": book.owner.username if book.owner else None,
|
"location": f"{book.location_place}/{book.location_bookshelf}/{book.location_shelf}",
|
||||||
"location": f"{book.location_place}/{book.location_bookshelf}/{book.location_shelf}",
|
"isbn": book.isbn,
|
||||||
"isbn": book.isbn,
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
click.echo(json.dumps(book_data, indent=2))
|
click.echo(json.dumps(book_data, indent=2))
|
||||||
else:
|
else:
|
||||||
# Table format
|
# Table format
|
||||||
@@ -215,20 +207,16 @@ def list_books(
|
|||||||
@click.option("--limit", type=int, default=20, help="Maximum number of results")
|
@click.option("--limit", type=int, default=20, help="Maximum number of results")
|
||||||
def search_books(
|
def search_books(
|
||||||
query: str,
|
query: str,
|
||||||
owner: Optional[str] = None,
|
owner: str | None = None,
|
||||||
output_format: str = "table",
|
output_format: str = "table",
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
):
|
) -> None:
|
||||||
"""Search books using query language (e.g., 'genre:thriller read>=2025-01-01')."""
|
"""Search books using query language (e.g., 'genre:thriller read>=2025-01-01')."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
book_service = BookService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = book_service.search_books_advanced(
|
results = library.search_books_advanced(query_string=query, limit=limit)
|
||||||
query_string=query, limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
if output_format == "json":
|
if output_format == "json":
|
||||||
click.echo(json.dumps(results, indent=2))
|
click.echo(json.dumps(results, indent=2))
|
||||||
@@ -263,19 +251,17 @@ def search_books(
|
|||||||
def import_book(
|
def import_book(
|
||||||
isbn: str,
|
isbn: str,
|
||||||
owner: str,
|
owner: str,
|
||||||
place: Optional[str] = None,
|
place: str | None = None,
|
||||||
bookshelf: Optional[str] = None,
|
bookshelf: str | None = None,
|
||||||
shelf: Optional[int] = None,
|
shelf: int | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""Import book data from ISBN using Google Books API."""
|
"""Import book data from ISBN using Google Books API."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = BookService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
book = service.import_book_from_isbn(
|
book = library.import_book_from_isbn(
|
||||||
isbn=isbn,
|
isbn=isbn,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
location_place=place,
|
location_place=place,
|
||||||
@@ -294,16 +280,14 @@ def import_book(
|
|||||||
@reading.command("start")
|
@reading.command("start")
|
||||||
@click.argument("book_id", type=int)
|
@click.argument("book_id", type=int)
|
||||||
@click.option("--owner", required=True, help="Username of reader")
|
@click.option("--owner", required=True, help="Username of reader")
|
||||||
def start_reading(book_id: int, owner: str):
|
def start_reading(book_id: int, owner: str) -> None:
|
||||||
"""Start a new reading session for a book."""
|
"""Start a new reading session for a book."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = ReadingService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reading_session = service.start_reading(book_id=book_id, user_id=user_id)
|
reading_session = library.start_reading(book_id=book_id, user_id=user_id)
|
||||||
click.echo(
|
click.echo(
|
||||||
f"Started reading session {reading_session.id} for book {book_id}"
|
f"Started reading session {reading_session.id} for book {book_id}"
|
||||||
)
|
)
|
||||||
@@ -317,16 +301,14 @@ def start_reading(book_id: int, owner: str):
|
|||||||
@click.option("--rating", type=click.IntRange(1, 5), help="Rating from 1-5")
|
@click.option("--rating", type=click.IntRange(1, 5), help="Rating from 1-5")
|
||||||
@click.option("--comments", help="Reading comments")
|
@click.option("--comments", help="Reading comments")
|
||||||
def finish_reading(
|
def finish_reading(
|
||||||
reading_id: int, rating: Optional[int] = None, comments: Optional[str] = None
|
reading_id: int, rating: int | None = None, comments: str | None = None
|
||||||
):
|
) -> None:
|
||||||
"""Finish a reading session."""
|
"""Finish a reading session."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = ReadingService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reading_session = service.finish_reading(
|
reading_session = library.finish_reading(
|
||||||
reading_id=reading_id,
|
reading_id=reading_id,
|
||||||
rating=rating,
|
rating=rating,
|
||||||
comments=comments,
|
comments=comments,
|
||||||
@@ -343,15 +325,13 @@ def finish_reading(
|
|||||||
@reading.command("drop")
|
@reading.command("drop")
|
||||||
@click.argument("reading_id", type=int)
|
@click.argument("reading_id", type=int)
|
||||||
@click.option("--comments", help="Comments about why dropped")
|
@click.option("--comments", help="Comments about why dropped")
|
||||||
def drop_reading(reading_id: int, comments: Optional[str] = None):
|
def drop_reading(reading_id: int, comments: str | None = None) -> None:
|
||||||
"""Mark a reading session as dropped."""
|
"""Mark a reading session as dropped."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = ReadingService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reading_session = service.drop_reading(
|
reading_session = library.drop_reading(
|
||||||
reading_id=reading_id, comments=comments
|
reading_id=reading_id, comments=comments
|
||||||
)
|
)
|
||||||
book_title = reading_session.book.title
|
book_title = reading_session.book.title
|
||||||
@@ -371,38 +351,36 @@ def drop_reading(reading_id: int, comments: Optional[str] = None):
|
|||||||
default="table",
|
default="table",
|
||||||
help="Output format",
|
help="Output format",
|
||||||
)
|
)
|
||||||
def list_readings(owner: str, current: bool = False, output_format: str = "table"):
|
def list_readings(
|
||||||
|
owner: str, current: bool = False, output_format: str = "table"
|
||||||
|
) -> None:
|
||||||
"""List reading sessions."""
|
"""List reading sessions."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = ReadingService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if current:
|
if current:
|
||||||
readings = service.get_current_readings(user_id=user_id)
|
readings = library.get_current_readings(user_id=user_id)
|
||||||
else:
|
else:
|
||||||
readings = service.get_reading_history(user_id=user_id)
|
readings = library.get_reading_history(user_id=user_id)
|
||||||
|
|
||||||
if output_format == "json":
|
if output_format == "json":
|
||||||
reading_data = []
|
reading_data = []
|
||||||
for reading in readings:
|
for reading in readings:
|
||||||
reading_data.append(
|
reading_data.append({
|
||||||
{
|
"id": reading.id,
|
||||||
"id": reading.id,
|
"book_id": reading.book_id,
|
||||||
"book_id": reading.book_id,
|
"book_title": reading.book.title,
|
||||||
"book_title": reading.book.title,
|
"start_date": reading.start_date.isoformat(),
|
||||||
"start_date": reading.start_date.isoformat(),
|
"end_date": reading.end_date.isoformat()
|
||||||
"end_date": reading.end_date.isoformat()
|
if reading.end_date
|
||||||
if reading.end_date
|
else None,
|
||||||
else None,
|
"finished": reading.finished,
|
||||||
"finished": reading.finished,
|
"dropped": reading.dropped,
|
||||||
"dropped": reading.dropped,
|
"rating": reading.rating,
|
||||||
"rating": reading.rating,
|
"comments": reading.comments,
|
||||||
"comments": reading.comments,
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
click.echo(json.dumps(reading_data, indent=2))
|
click.echo(json.dumps(reading_data, indent=2))
|
||||||
else:
|
else:
|
||||||
# Table format
|
# Table format
|
||||||
@@ -437,16 +415,14 @@ def list_readings(owner: str, current: bool = False, output_format: str = "table
|
|||||||
@wishlist.command("add")
|
@wishlist.command("add")
|
||||||
@click.argument("book_id", type=int)
|
@click.argument("book_id", type=int)
|
||||||
@click.option("--owner", required=True, help="Username")
|
@click.option("--owner", required=True, help="Username")
|
||||||
def add_to_wishlist(book_id: int, owner: str):
|
def add_to_wishlist(book_id: int, owner: str) -> None:
|
||||||
"""Add a book to wishlist."""
|
"""Add a book to wishlist."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = WishlistService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wishlist_item = service.add_to_wishlist(book_id=book_id, user_id=user_id)
|
wishlist_item = library.add_to_wishlist(book_id=book_id, user_id=user_id)
|
||||||
book_title = wishlist_item.book.title
|
book_title = wishlist_item.book.title
|
||||||
click.echo(f"Added '{book_title}' to wishlist")
|
click.echo(f"Added '{book_title}' to wishlist")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -457,16 +433,14 @@ def add_to_wishlist(book_id: int, owner: str):
|
|||||||
@wishlist.command("remove")
|
@wishlist.command("remove")
|
||||||
@click.argument("book_id", type=int)
|
@click.argument("book_id", type=int)
|
||||||
@click.option("--owner", required=True, help="Username")
|
@click.option("--owner", required=True, help="Username")
|
||||||
def remove_from_wishlist(book_id: int, owner: str):
|
def remove_from_wishlist(book_id: int, owner: str) -> None:
|
||||||
"""Remove a book from wishlist."""
|
"""Remove a book from wishlist."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = WishlistService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if service.remove_from_wishlist(book_id=book_id, user_id=user_id):
|
if library.remove_from_wishlist(book_id=book_id, user_id=user_id):
|
||||||
click.echo(f"Removed book {book_id} from wishlist")
|
click.echo(f"Removed book {book_id} from wishlist")
|
||||||
else:
|
else:
|
||||||
click.echo(f"Book {book_id} was not in wishlist")
|
click.echo(f"Book {book_id} was not in wishlist")
|
||||||
@@ -484,28 +458,24 @@ def remove_from_wishlist(book_id: int, owner: str):
|
|||||||
default="table",
|
default="table",
|
||||||
help="Output format",
|
help="Output format",
|
||||||
)
|
)
|
||||||
def list_wishlist(owner: str, output_format: str = "table"):
|
def list_wishlist(owner: str, output_format: str = "table") -> None:
|
||||||
"""Show user's wishlist."""
|
"""Show user's wishlist."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
service = WishlistService()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wishlist_items = service.get_wishlist(user_id=user_id)
|
wishlist_items = library.get_wishlist(user_id=user_id)
|
||||||
|
|
||||||
if output_format == "json":
|
if output_format == "json":
|
||||||
wishlist_data = []
|
wishlist_data = []
|
||||||
for item in wishlist_items:
|
for item in wishlist_items:
|
||||||
wishlist_data.append(
|
wishlist_data.append({
|
||||||
{
|
"book_id": item.book_id,
|
||||||
"book_id": item.book_id,
|
"title": item.book.title,
|
||||||
"title": item.book.title,
|
"authors": [author.name for author in item.book.authors],
|
||||||
"authors": [author.name for author in item.book.authors],
|
"wishlisted_date": item.wishlisted_date.isoformat(),
|
||||||
"wishlisted_date": item.wishlisted_date.isoformat(),
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
click.echo(json.dumps(wishlist_data, indent=2))
|
click.echo(json.dumps(wishlist_data, indent=2))
|
||||||
else:
|
else:
|
||||||
# Table format
|
# Table format
|
||||||
@@ -531,28 +501,24 @@ def list_wishlist(owner: str, output_format: str = "table"):
|
|||||||
|
|
||||||
|
|
||||||
# Database commands
|
# Database commands
|
||||||
@db.command("init")
|
@db_group.command("init")
|
||||||
def init_db():
|
def init_db() -> None:
|
||||||
"""Initialize the database."""
|
"""Initialize the database."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from .db import db
|
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
click.echo("Database initialized.")
|
click.echo("Database initialized.")
|
||||||
|
|
||||||
|
|
||||||
@db.command("seed")
|
@db_group.command("seed")
|
||||||
@click.option("--owner", default="test_user", help="Default owner for seed data")
|
@click.option("--owner", default="test_user", help="Default owner for seed data")
|
||||||
def seed_db(owner: str):
|
def seed_db(owner: str) -> None:
|
||||||
"""Create some sample data for testing."""
|
"""Create some sample data for testing."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
user_id = ensure_user_exists(app, owner)
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
book_service = BookService()
|
|
||||||
|
|
||||||
sample_books = [
|
sample_books = [
|
||||||
{
|
{
|
||||||
"title": "The Hobbit",
|
"title": "The Hobbit",
|
||||||
@@ -589,7 +555,7 @@ def seed_db(owner: str):
|
|||||||
created_books = []
|
created_books = []
|
||||||
for book_data in sample_books:
|
for book_data in sample_books:
|
||||||
try:
|
try:
|
||||||
book = book_service.create_book(owner_id=user_id, **book_data)
|
book = library.create_book(owner_id=user_id, **book_data) # ty:ignore[invalid-argument-type]
|
||||||
created_books.append(book)
|
created_books.append(book)
|
||||||
click.echo(f"Created: {book.title}")
|
click.echo(f"Created: {book.title}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -598,15 +564,12 @@ def seed_db(owner: str):
|
|||||||
click.echo(f"Created {len(created_books)} sample books for user '{owner}'")
|
click.echo(f"Created {len(created_books)} sample books for user '{owner}'")
|
||||||
|
|
||||||
|
|
||||||
@db.command("status")
|
@db_group.command("status")
|
||||||
def db_status():
|
def db_status() -> None:
|
||||||
"""Show database status and statistics."""
|
"""Show database status and statistics."""
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from .db import db
|
|
||||||
from .models import Author, Book, Genre, Reading, User, Wishlist
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
book_count = db.session.execute(db.select(db.func.count(Book.id))).scalar()
|
book_count = db.session.execute(db.select(db.func.count(Book.id))).scalar()
|
||||||
author_count = db.session.execute(
|
author_count = db.session.execute(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
@@ -53,7 +53,7 @@ class GoogleBook(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
authors: list[str] = []
|
authors: list[str] = []
|
||||||
publisher: str = ""
|
publisher: str = ""
|
||||||
publishedDate: Optional[date | int] = None
|
publishedDate: date | int | None = None
|
||||||
description: str = ""
|
description: str = ""
|
||||||
industryIdentifiers: list[dict[str, str]] = []
|
industryIdentifiers: list[dict[str, str]] = []
|
||||||
pageCount: int = 0
|
pageCount: int = 0
|
||||||
|
|||||||
698
src/hxbooks/library.py
Normal file
698
src/hxbooks/library.py
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
"""
|
||||||
|
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 sqlalchemy import ColumnElement, and_, or_
|
||||||
|
from sqlalchemy.orm import InstrumentedAttribute, joinedload
|
||||||
|
|
||||||
|
from hxbooks.search import 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) -> 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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
results = []
|
||||||
|
for book in result.scalars().unique().all():
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _build_field_condition(field_filter: FieldFilter) -> 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
|
||||||
|
if field == Field.TITLE:
|
||||||
|
field_attr = Book.title
|
||||||
|
elif field == Field.AUTHOR:
|
||||||
|
return Book.authors.any(_apply_operator(Author.name, operator, value))
|
||||||
|
elif field == Field.GENRE:
|
||||||
|
return Book.genres.any(_apply_operator(Genre.name, operator, value))
|
||||||
|
elif field == Field.ISBN:
|
||||||
|
field_attr = Book.isbn
|
||||||
|
elif field == Field.PLACE:
|
||||||
|
field_attr = Book.location_place
|
||||||
|
elif field == Field.BOOKSHELF:
|
||||||
|
field_attr = Book.location_bookshelf
|
||||||
|
elif field == Field.SHELF:
|
||||||
|
field_attr = Book.location_shelf
|
||||||
|
elif field == Field.ADDED_DATE:
|
||||||
|
field_attr = Book.added_date
|
||||||
|
elif field == Field.BOUGHT_DATE:
|
||||||
|
field_attr = Book.bought_date
|
||||||
|
elif field == Field.LOANED_DATE:
|
||||||
|
field_attr = Book.loaned_date
|
||||||
|
elif field == Field.OWNER:
|
||||||
|
return Book.owner.has(_apply_operator(User.username, operator, value))
|
||||||
|
else:
|
||||||
|
# Unknown field, skip
|
||||||
|
return None
|
||||||
|
|
||||||
|
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,5 +1,4 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, ForeignKey, String
|
from sqlalchemy import JSON, ForeignKey, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -7,67 +6,67 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from .db import db
|
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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
username: Mapped[str] = mapped_column()
|
username: Mapped[str] = mapped_column()
|
||||||
saved_searches: Mapped[dict] = mapped_column(JSON, default=dict)
|
saved_searches: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
readings: Mapped[list["Reading"]] = relationship(back_populates="user")
|
readings: Mapped[list[Reading]] = relationship(back_populates="user")
|
||||||
owned_books: Mapped[list["Book"]] = relationship(back_populates="owner")
|
owned_books: Mapped[list[Book]] = relationship(back_populates="owner")
|
||||||
wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user")
|
wishes: Mapped[list[Wishlist]] = relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
class Author(db.Model): # type: ignore[name-defined]
|
class Author(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(200))
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
books: Mapped[list["Book"]] = relationship(
|
books: Mapped[list[Book]] = relationship(
|
||||||
secondary="book_author", back_populates="authors"
|
secondary="book_author", back_populates="authors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Genre(db.Model): # type: ignore[name-defined]
|
class Genre(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(100))
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
books: Mapped[list["Book"]] = relationship(
|
books: Mapped[list[Book]] = relationship(
|
||||||
secondary="book_genre", back_populates="genres"
|
secondary="book_genre", back_populates="genres"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookAuthor(db.Model): # type: ignore[name-defined]
|
class BookAuthor(db.Model): # ty:ignore[unsupported-base]
|
||||||
__tablename__ = "book_author"
|
__tablename__ = "book_author"
|
||||||
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
|
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
|
||||||
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), primary_key=True)
|
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
class BookGenre(db.Model): # type: ignore[name-defined]
|
class BookGenre(db.Model): # ty:ignore[unsupported-base]
|
||||||
__tablename__ = "book_genre"
|
__tablename__ = "book_genre"
|
||||||
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
|
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
|
||||||
genre_id: Mapped[int] = mapped_column(ForeignKey("genre.id"), primary_key=True)
|
genre_id: Mapped[int] = mapped_column(ForeignKey("genre.id"), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
class Book(db.Model): # type: ignore[name-defined]
|
class Book(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(String(500), default="")
|
title: Mapped[str] = mapped_column(String(500), default="")
|
||||||
description: Mapped[str] = mapped_column(default="")
|
description: Mapped[str] = mapped_column(default="")
|
||||||
first_published: Mapped[Optional[int]] = mapped_column(default=None)
|
first_published: Mapped[int | None] = mapped_column(default=None)
|
||||||
edition: Mapped[str] = mapped_column(String(200), default="")
|
edition: Mapped[str] = mapped_column(String(200), default="")
|
||||||
publisher: Mapped[str] = mapped_column(String(200), default="")
|
publisher: Mapped[str] = mapped_column(String(200), default="")
|
||||||
isbn: Mapped[str] = mapped_column(String(20), default="")
|
isbn: Mapped[str] = mapped_column(String(20), default="")
|
||||||
notes: Mapped[str] = mapped_column(default="")
|
notes: Mapped[str] = mapped_column(default="")
|
||||||
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
|
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||||
bought_date: Mapped[Optional[date]] = mapped_column(default=None)
|
bought_date: Mapped[date | None] = mapped_column(default=None)
|
||||||
|
|
||||||
# Location hierarchy
|
# Location hierarchy
|
||||||
location_place: Mapped[str] = mapped_column(String(100), default="")
|
location_place: Mapped[str] = mapped_column(String(100), default="")
|
||||||
location_bookshelf: Mapped[str] = mapped_column(String(100), default="")
|
location_bookshelf: Mapped[str] = mapped_column(String(100), default="")
|
||||||
location_shelf: Mapped[Optional[int]] = mapped_column(default=None)
|
location_shelf: Mapped[int | None] = mapped_column(default=None)
|
||||||
|
|
||||||
# Loaning
|
# Loaning
|
||||||
loaned_to: Mapped[str] = mapped_column(String(200), default="")
|
loaned_to: Mapped[str] = mapped_column(String(200), default="")
|
||||||
loaned_date: Mapped[Optional[date]] = mapped_column(default=None)
|
loaned_date: Mapped[date | None] = mapped_column(default=None)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"))
|
owner_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
|
||||||
owner: Mapped[Optional[User]] = relationship(back_populates="owned_books")
|
owner: Mapped[User | None] = relationship(back_populates="owned_books")
|
||||||
|
|
||||||
authors: Mapped[list[Author]] = relationship(
|
authors: Mapped[list[Author]] = relationship(
|
||||||
secondary="book_author", back_populates="books"
|
secondary="book_author", back_populates="books"
|
||||||
@@ -76,21 +75,21 @@ class Book(db.Model): # type: ignore[name-defined]
|
|||||||
secondary="book_genre", back_populates="books"
|
secondary="book_genre", back_populates="books"
|
||||||
)
|
)
|
||||||
|
|
||||||
readings: Mapped[list["Reading"]] = relationship(
|
readings: Mapped[list[Reading]] = relationship(
|
||||||
back_populates="book", cascade="delete, delete-orphan"
|
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"
|
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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
start_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
|
start_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
|
||||||
end_date: Mapped[Optional[date]] = mapped_column(default=None)
|
end_date: Mapped[date | None] = mapped_column(default=None)
|
||||||
finished: Mapped[bool] = mapped_column(default=False)
|
finished: Mapped[bool] = mapped_column(default=False)
|
||||||
dropped: 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="")
|
comments: Mapped[str] = mapped_column(default="")
|
||||||
|
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
||||||
@@ -100,7 +99,7 @@ class Reading(db.Model): # type: ignore[name-defined]
|
|||||||
book: Mapped[Book] = 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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
wishlisted_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
|
wishlisted_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
Search functionality for HXBooks.
|
Search functionality for HXBooks.
|
||||||
|
|
||||||
Provides query parsing and search logic for finding books with advanced syntax.
|
Provides query parsing and search logic for finding books with advanced syntax.
|
||||||
Currently implements basic search - will be enhanced with pyparsing for advanced queries.
|
Currently implements basic search - will be enhanced with pyparsing for advanced queries
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from re import A
|
|
||||||
from typing import Any, Dict, List, Optional, Union
|
|
||||||
|
|
||||||
import pyparsing as pp
|
import pyparsing as pp
|
||||||
|
|
||||||
@@ -44,13 +42,16 @@ class Field(StrEnum):
|
|||||||
OWNER = "owner"
|
OWNER = "owner"
|
||||||
|
|
||||||
|
|
||||||
|
ValueT = str | int | float | date
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FieldFilter:
|
class FieldFilter:
|
||||||
"""Represents a field-specific search filter."""
|
"""Represents a field-specific search filter."""
|
||||||
|
|
||||||
field: Field
|
field: Field
|
||||||
operator: ComparisonOperator
|
operator: ComparisonOperator
|
||||||
value: Union[str, int, float, date]
|
value: ValueT
|
||||||
negated: bool = False
|
negated: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -58,8 +59,8 @@ class FieldFilter:
|
|||||||
class SearchQuery:
|
class SearchQuery:
|
||||||
"""Enhanced structured representation of a search query."""
|
"""Enhanced structured representation of a search query."""
|
||||||
|
|
||||||
text_terms: List[str] = field(default_factory=list)
|
text_terms: list[str] = field(default_factory=list)
|
||||||
field_filters: List[FieldFilter] = field(default_factory=list)
|
field_filters: list[FieldFilter] = field(default_factory=list)
|
||||||
boolean_operator: str = "AND" # Default to AND for multiple terms
|
boolean_operator: str = "AND" # Default to AND for multiple terms
|
||||||
|
|
||||||
|
|
||||||
@@ -77,11 +78,11 @@ class QueryParser:
|
|||||||
- Parentheses: (genre:fantasy OR genre:scifi) AND rating>=4
|
- Parentheses: (genre:fantasy OR genre:scifi) AND rating>=4
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
"""Initialize the pyparsing grammar."""
|
"""Initialize the pyparsing grammar."""
|
||||||
self._build_grammar()
|
self._build_grammar()
|
||||||
|
|
||||||
def _build_grammar(self):
|
def _build_grammar(self) -> None:
|
||||||
"""Build the pyparsing grammar for the query language."""
|
"""Build the pyparsing grammar for the query language."""
|
||||||
|
|
||||||
# Basic tokens
|
# Basic tokens
|
||||||
@@ -110,9 +111,9 @@ class QueryParser:
|
|||||||
text_term = quoted_string | pp.Regex(r'[^\s():"]+(?![:\<\>=!])')
|
text_term = quoted_string | pp.Regex(r'[^\s():"]+(?![:\<\>=!])')
|
||||||
|
|
||||||
# Boolean operators
|
# Boolean operators
|
||||||
and_op = pp.CaselessKeyword("AND")
|
# and_op = pp.CaselessKeyword("AND")
|
||||||
or_op = pp.CaselessKeyword("OR")
|
# or_op = pp.CaselessKeyword("OR")
|
||||||
not_op = pp.CaselessKeyword("NOT")
|
# not_op = pp.CaselessKeyword("NOT")
|
||||||
|
|
||||||
# Basic search element
|
# Basic search element
|
||||||
search_element = field_filter | text_term
|
search_element = field_filter | text_term
|
||||||
@@ -132,7 +133,7 @@ class QueryParser:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
parsed_elements = self.grammar.parse_string(query_string, parse_all=True)
|
parsed_elements = self.grammar.parse_string(query_string, parse_all=True)
|
||||||
except pp.ParseException as e:
|
except pp.ParseException:
|
||||||
# If parsing fails, fall back to simple text search
|
# If parsing fails, fall back to simple text search
|
||||||
return SearchQuery(text_terms=[query_string])
|
return SearchQuery(text_terms=[query_string])
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ class QueryParser:
|
|||||||
operator = ComparisonOperator.EQUALS
|
operator = ComparisonOperator.EQUALS
|
||||||
|
|
||||||
# Convert value to appropriate type
|
# Convert value to appropriate type
|
||||||
value = self._convert_value(field, value_str)
|
value = _convert_value(field, value_str)
|
||||||
|
|
||||||
field_filters.append(
|
field_filters.append(
|
||||||
FieldFilter(
|
FieldFilter(
|
||||||
@@ -171,32 +172,31 @@ class QueryParser:
|
|||||||
|
|
||||||
return SearchQuery(text_terms=text_terms, field_filters=field_filters)
|
return SearchQuery(text_terms=text_terms, field_filters=field_filters)
|
||||||
|
|
||||||
def _convert_value(
|
|
||||||
self, field: Field, value_str: str
|
|
||||||
) -> Union[str, int, float, date]:
|
|
||||||
"""Convert string value to appropriate type based on field."""
|
|
||||||
|
|
||||||
# Date fields
|
def _convert_value(field: Field, value_str: str) -> str | int | float | date:
|
||||||
if field in [
|
"""Convert string value to appropriate type based on field."""
|
||||||
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
|
# Date fields
|
||||||
if field in [Field.RATING, Field.SHELF, Field.YEAR]:
|
if field in {
|
||||||
try:
|
Field.READ_DATE,
|
||||||
if "." in value_str:
|
Field.BOUGHT_DATE,
|
||||||
return float(value_str)
|
Field.ADDED_DATE,
|
||||||
else:
|
Field.LOANED_DATE,
|
||||||
return int(value_str)
|
}:
|
||||||
except ValueError:
|
try:
|
||||||
return value_str
|
return datetime.strptime(value_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return value_str
|
||||||
|
|
||||||
# String fields (default)
|
# Numeric fields
|
||||||
return value_str
|
if field in {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 (default)
|
||||||
|
return value_str
|
||||||
|
|||||||
@@ -1,738 +0,0 @@
|
|||||||
"""
|
|
||||||
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 datetime import date, datetime
|
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
||||||
|
|
||||||
from sqlalchemy import and_, or_, text
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
from hxbooks.search import QueryParser
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class BookService:
|
|
||||||
"""Service for book-related operations."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.query_parser = QueryParser()
|
|
||||||
|
|
||||||
def create_book(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
owner_id: Optional[int] = None,
|
|
||||||
authors: Optional[List[str]] = None,
|
|
||||||
genres: Optional[List[str]] = None,
|
|
||||||
isbn: Optional[str] = None,
|
|
||||||
publisher: Optional[str] = None,
|
|
||||||
edition: Optional[str] = None,
|
|
||||||
description: Optional[str] = None,
|
|
||||||
notes: Optional[str] = None,
|
|
||||||
location_place: Optional[str] = None,
|
|
||||||
location_bookshelf: Optional[str] = None,
|
|
||||||
location_shelf: Optional[int] = None,
|
|
||||||
first_published: Optional[int] = None,
|
|
||||||
bought_date: Optional[date] = 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 authors:
|
|
||||||
author_name = author_name.strip()
|
|
||||||
if author_name:
|
|
||||||
author = self._get_or_create_author(author_name)
|
|
||||||
book.authors.append(author)
|
|
||||||
|
|
||||||
# Handle genres
|
|
||||||
if genres:
|
|
||||||
for genre_name in genres:
|
|
||||||
genre_name = genre_name.strip()
|
|
||||||
if genre_name:
|
|
||||||
genre = self._get_or_create_genre(genre_name)
|
|
||||||
book.genres.append(genre)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return book
|
|
||||||
|
|
||||||
def get_book(self, book_id: int) -> Optional[Book]:
|
|
||||||
"""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(
|
|
||||||
self,
|
|
||||||
book_id: int,
|
|
||||||
title: Optional[str] = None,
|
|
||||||
authors: Optional[List[str]] = None,
|
|
||||||
genres: Optional[List[str]] = None,
|
|
||||||
isbn: Optional[str] = None,
|
|
||||||
publisher: Optional[str] = None,
|
|
||||||
edition: Optional[str] = None,
|
|
||||||
description: Optional[str] = None,
|
|
||||||
notes: Optional[str] = None,
|
|
||||||
location_place: Optional[str] = None,
|
|
||||||
location_bookshelf: Optional[str] = None,
|
|
||||||
location_shelf: Optional[int] = None,
|
|
||||||
first_published: Optional[int] = None,
|
|
||||||
bought_date: Optional[date] = None,
|
|
||||||
) -> Optional[Book]:
|
|
||||||
"""Update a book with new details."""
|
|
||||||
book = self.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 authors:
|
|
||||||
author_name = author_name.strip()
|
|
||||||
if author_name:
|
|
||||||
author = self._get_or_create_author(author_name)
|
|
||||||
book.authors.append(author)
|
|
||||||
|
|
||||||
# Update genres
|
|
||||||
if genres is not None:
|
|
||||||
book.genres.clear()
|
|
||||||
for genre_name in genres:
|
|
||||||
genre_name = genre_name.strip()
|
|
||||||
if genre_name:
|
|
||||||
genre = self._get_or_create_genre(genre_name)
|
|
||||||
book.genres.append(genre)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return book
|
|
||||||
|
|
||||||
def delete_book(self, book_id: int) -> bool:
|
|
||||||
"""Delete a book and all related data."""
|
|
||||||
book = self.get_book(book_id)
|
|
||||||
if not book:
|
|
||||||
return False
|
|
||||||
|
|
||||||
db.session.delete(book)
|
|
||||||
db.session.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def search_books(
|
|
||||||
self,
|
|
||||||
text_query: Optional[str] = None,
|
|
||||||
owner_username: Optional[str] = None,
|
|
||||||
location_place: Optional[str] = None,
|
|
||||||
location_bookshelf: Optional[str] = None,
|
|
||||||
location_shelf: Optional[int] = None,
|
|
||||||
author_name: Optional[str] = None,
|
|
||||||
genre_name: Optional[str] = None,
|
|
||||||
isbn: Optional[str] = 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:
|
|
||||||
# Create aliases to avoid table name conflicts
|
|
||||||
author_alias = db.aliased(Author)
|
|
||||||
genre_alias = db.aliased(Genre)
|
|
||||||
|
|
||||||
text_conditions = []
|
|
||||||
# Search in title, description, notes
|
|
||||||
text_conditions.append(Book.title.icontains(text_query))
|
|
||||||
text_conditions.append(Book.description.icontains(text_query))
|
|
||||||
text_conditions.append(Book.notes.icontains(text_query))
|
|
||||||
text_conditions.append(Book.publisher.icontains(text_query))
|
|
||||||
|
|
||||||
# Search in authors and genres via subqueries to avoid cartesian products
|
|
||||||
author_subquery = (
|
|
||||||
db.select(Book.id)
|
|
||||||
.join(Book.authors)
|
|
||||||
.filter(Author.name.icontains(text_query))
|
|
||||||
)
|
|
||||||
genre_subquery = (
|
|
||||||
db.select(Book.id)
|
|
||||||
.join(Book.genres)
|
|
||||||
.filter(Genre.name.icontains(text_query))
|
|
||||||
)
|
|
||||||
|
|
||||||
text_conditions.append(Book.id.in_(author_subquery))
|
|
||||||
text_conditions.append(Book.id.in_(genre_subquery))
|
|
||||||
|
|
||||||
conditions.append(or_(*text_conditions))
|
|
||||||
|
|
||||||
# Owner filter
|
|
||||||
if owner_username:
|
|
||||||
query = query.join(Book.owner)
|
|
||||||
conditions.append(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:
|
|
||||||
author_subquery = (
|
|
||||||
db.select(Book.id)
|
|
||||||
.join(Book.authors)
|
|
||||||
.filter(Author.name.icontains(author_name))
|
|
||||||
)
|
|
||||||
conditions.append(Book.id.in_(author_subquery))
|
|
||||||
|
|
||||||
# Genre filter
|
|
||||||
if genre_name:
|
|
||||||
genre_subquery = (
|
|
||||||
db.select(Book.id)
|
|
||||||
.join(Book.genres)
|
|
||||||
.filter(Genre.name.icontains(genre_name))
|
|
||||||
)
|
|
||||||
conditions.append(Book.id.in_(genre_subquery))
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
def search_books_advanced(
|
|
||||||
self, query_string: str, limit: int = 50
|
|
||||||
) -> Sequence[Book]:
|
|
||||||
"""Advanced search with field filters supporting comparison operators."""
|
|
||||||
parsed_query = self.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 parsed_query.text_terms:
|
|
||||||
text_query = text_query.strip()
|
|
||||||
if text_query:
|
|
||||||
text_conditions = []
|
|
||||||
# Search in title, description, notes
|
|
||||||
text_conditions.append(Book.title.icontains(text_query))
|
|
||||||
text_conditions.append(Book.description.icontains(text_query))
|
|
||||||
text_conditions.append(Book.notes.icontains(text_query))
|
|
||||||
text_conditions.append(Book.publisher.icontains(text_query))
|
|
||||||
|
|
||||||
# Search in authors and genres via subqueries
|
|
||||||
author_subquery = (
|
|
||||||
db.select(Book.id)
|
|
||||||
.join(Book.authors)
|
|
||||||
.filter(Author.name.icontains(text_query))
|
|
||||||
)
|
|
||||||
genre_subquery = (
|
|
||||||
db.select(Book.id)
|
|
||||||
.join(Book.genres)
|
|
||||||
.filter(Genre.name.icontains(text_query))
|
|
||||||
)
|
|
||||||
|
|
||||||
text_conditions.append(Book.id.in_(author_subquery))
|
|
||||||
text_conditions.append(Book.id.in_(genre_subquery))
|
|
||||||
|
|
||||||
conditions.append(or_(*text_conditions))
|
|
||||||
|
|
||||||
# Advanced field filters
|
|
||||||
if parsed_query.field_filters:
|
|
||||||
for field_filter in parsed_query.field_filters:
|
|
||||||
condition = self._build_field_condition(field_filter)
|
|
||||||
|
|
||||||
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()
|
|
||||||
results = []
|
|
||||||
for book in result.scalars().unique().all():
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def _build_field_condition(self, field_filter: FieldFilter):
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
if field == Field.TITLE:
|
|
||||||
field_attr = Book.title
|
|
||||||
elif field == Field.AUTHOR:
|
|
||||||
return Book.authors.any(self._apply_operator(Author.name, operator, value))
|
|
||||||
elif field == Field.GENRE:
|
|
||||||
return Book.genres.any(self._apply_operator(Genre.name, operator, value))
|
|
||||||
elif field == Field.ISBN:
|
|
||||||
field_attr = Book.isbn
|
|
||||||
elif field == Field.PLACE:
|
|
||||||
field_attr = Book.location_place
|
|
||||||
elif field == Field.BOOKSHELF:
|
|
||||||
field_attr = Book.location_bookshelf
|
|
||||||
elif field == Field.SHELF:
|
|
||||||
field_attr = Book.location_shelf
|
|
||||||
elif field == Field.ADDED_DATE:
|
|
||||||
field_attr = Book.added_date
|
|
||||||
elif field == Field.BOUGHT_DATE:
|
|
||||||
field_attr = Book.bought_date
|
|
||||||
elif field == Field.LOANED_DATE:
|
|
||||||
field_attr = Book.loaned_date
|
|
||||||
elif field == Field.OWNER:
|
|
||||||
return Book.owner.has(self._apply_operator(User.username, operator, value))
|
|
||||||
else:
|
|
||||||
# Unknown field, skip
|
|
||||||
return None
|
|
||||||
|
|
||||||
condition = self._apply_operator(field_attr, operator, value)
|
|
||||||
return condition
|
|
||||||
|
|
||||||
def _apply_operator(self, field_attr, operator, value):
|
|
||||||
"""
|
|
||||||
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(
|
|
||||||
self,
|
|
||||||
isbn: str,
|
|
||||||
owner_id: Optional[int] = None,
|
|
||||||
location_place: Optional[str] = None,
|
|
||||||
location_bookshelf: Optional[str] = None,
|
|
||||||
location_shelf: Optional[int] = 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
|
|
||||||
|
|
||||||
return self.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=google_book_data.published_year,
|
|
||||||
location_place=location_place,
|
|
||||||
location_bookshelf=location_bookshelf,
|
|
||||||
location_shelf=location_shelf,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_books_by_location(
|
|
||||||
self, place: str, bookshelf: Optional[str] = None, shelf: Optional[int] = None
|
|
||||||
) -> Sequence[Book]:
|
|
||||||
"""Get all books at a specific location."""
|
|
||||||
return self.search_books(
|
|
||||||
location_place=place,
|
|
||||||
location_bookshelf=bookshelf,
|
|
||||||
location_shelf=shelf,
|
|
||||||
limit=1000, # Large limit for location queries
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_or_create_author(self, 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(self, 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
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingService:
|
|
||||||
"""Service for reading-related operations."""
|
|
||||||
|
|
||||||
def start_reading(
|
|
||||||
self, book_id: int, user_id: int, start_date: Optional[date] = 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(
|
|
||||||
self,
|
|
||||||
reading_id: int,
|
|
||||||
rating: Optional[int] = None,
|
|
||||||
comments: Optional[str] = None,
|
|
||||||
end_date: Optional[date] = 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(
|
|
||||||
self,
|
|
||||||
reading_id: int,
|
|
||||||
comments: Optional[str] = None,
|
|
||||||
end_date: Optional[date] = 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(self, 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(self, 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()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WishlistService:
|
|
||||||
"""Service for wishlist operations."""
|
|
||||||
|
|
||||||
def add_to_wishlist(self, 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(self, 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(self, 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()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
|
||||||
"""Service for user operations."""
|
|
||||||
|
|
||||||
def create_user(self, 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(self, username: str) -> Optional[User]:
|
|
||||||
"""Get a user by username."""
|
|
||||||
return db.session.execute(
|
|
||||||
db.select(User).filter(User.username == username)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
def list_users(self) -> Sequence[User]:
|
|
||||||
"""List all users."""
|
|
||||||
return (
|
|
||||||
db.session.execute(db.select(User).order_by(User.username)).scalars().all()
|
|
||||||
)
|
|
||||||
@@ -4,16 +4,17 @@ Test configuration and fixtures for HXBooks.
|
|||||||
Provides isolated test database, Flask app instances, and CLI testing utilities.
|
Provides isolated test database, Flask app instances, and CLI testing utilities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tempfile
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from hxbooks import cli, create_app
|
from hxbooks import cli
|
||||||
|
from hxbooks.app import create_app
|
||||||
from hxbooks.db import db
|
from hxbooks.db import db
|
||||||
from hxbooks.models import User
|
from hxbooks.models import User
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ def test_user(app: Flask) -> User:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_session(app: Flask):
|
def db_session(app: Flask) -> Generator[Session]:
|
||||||
"""Create database session for direct database testing."""
|
"""Create database session for direct database testing."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
yield db.session
|
yield db.session
|
||||||
|
|||||||
@@ -6,22 +6,21 @@ Tests all CLI commands for correct behavior, database integration, and output fo
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import tempfile
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
from hxbooks.cli import cli
|
from hxbooks.cli import cli
|
||||||
from hxbooks.db import db
|
from hxbooks.db import db
|
||||||
from hxbooks.models import Author, Book, Genre, Reading, User, Wishlist
|
from hxbooks.models import Author, Book, Genre, Reading, User
|
||||||
|
|
||||||
|
|
||||||
class TestBookAddCommand:
|
class TestBookAddCommand:
|
||||||
"""Test the 'hxbooks book add' command."""
|
"""Test the 'hxbooks book add' command."""
|
||||||
|
|
||||||
def test_book_add_basic(self, app, cli_runner):
|
def test_book_add_basic(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test basic book addition with title and owner."""
|
"""Test basic book addition with title and owner."""
|
||||||
# Run the CLI command
|
# Run the CLI command
|
||||||
result = cli_runner.invoke(
|
result = cli_runner.invoke(
|
||||||
@@ -70,7 +69,8 @@ class TestBookAddCommand:
|
|||||||
|
|
||||||
# Check book was created with correct fields
|
# Check book was created with correct fields
|
||||||
books = (
|
books = (
|
||||||
db.session.execute(db.select(Book).join(Book.authors).join(Book.genres))
|
db.session
|
||||||
|
.execute(db.select(Book).join(Book.authors).join(Book.genres))
|
||||||
.unique()
|
.unique()
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
@@ -106,7 +106,7 @@ class TestBookAddCommand:
|
|||||||
assert book in genre.books
|
assert book in genre.books
|
||||||
assert genre in book.genres
|
assert genre in book.genres
|
||||||
|
|
||||||
def test_book_add_minimal_fields(self, app, cli_runner):
|
def test_book_add_minimal_fields(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test book addition with only required fields."""
|
"""Test book addition with only required fields."""
|
||||||
result = cli_runner.invoke(
|
result = cli_runner.invoke(
|
||||||
cli, ["book", "add", "Minimal Book", "--owner", "alice"]
|
cli, ["book", "add", "Minimal Book", "--owner", "alice"]
|
||||||
@@ -124,7 +124,9 @@ class TestBookAddCommand:
|
|||||||
assert len(book.authors) == 0 # No authors provided
|
assert len(book.authors) == 0 # No authors provided
|
||||||
assert len(book.genres) == 0 # No genres provided
|
assert len(book.genres) == 0 # No genres provided
|
||||||
|
|
||||||
def test_book_add_missing_owner_fails(self, app, cli_runner):
|
def test_book_add_missing_owner_fails(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test that book addition fails when owner is not provided."""
|
"""Test that book addition fails when owner is not provided."""
|
||||||
result = cli_runner.invoke(
|
result = cli_runner.invoke(
|
||||||
cli,
|
cli,
|
||||||
@@ -144,14 +146,14 @@ class TestBookAddCommand:
|
|||||||
class TestBookListCommand:
|
class TestBookListCommand:
|
||||||
"""Test the 'hxbooks book list' command."""
|
"""Test the 'hxbooks book list' command."""
|
||||||
|
|
||||||
def test_book_list_empty(self, app, cli_runner):
|
def test_book_list_empty(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing books when database is empty."""
|
"""Test listing books when database is empty."""
|
||||||
result = cli_runner.invoke(cli, ["book", "list"])
|
result = cli_runner.invoke(cli, ["book", "list"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No books found." in result.output
|
assert "No books found." in result.output
|
||||||
|
|
||||||
def test_book_list_with_books(self, app, cli_runner):
|
def test_book_list_with_books(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing books in table format."""
|
"""Test listing books in table format."""
|
||||||
# Add test data
|
# Add test data
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -172,7 +174,7 @@ class TestBookListCommand:
|
|||||||
assert "alice" in result.output
|
assert "alice" in result.output
|
||||||
assert "bob" in result.output
|
assert "bob" in result.output
|
||||||
|
|
||||||
def test_book_list_json_format(self, app, cli_runner):
|
def test_book_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing books in JSON format."""
|
"""Test listing books in JSON format."""
|
||||||
# Add test data
|
# Add test data
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -201,7 +203,7 @@ class TestBookListCommand:
|
|||||||
assert book["owner"] == "alice"
|
assert book["owner"] == "alice"
|
||||||
assert book["isbn"] == "1234567890"
|
assert book["isbn"] == "1234567890"
|
||||||
|
|
||||||
def test_book_list_filter_by_owner(self, app, cli_runner):
|
def test_book_list_filter_by_owner(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test filtering books by owner."""
|
"""Test filtering books by owner."""
|
||||||
# Add books for different owners
|
# Add books for different owners
|
||||||
cli_runner.invoke(cli, ["book", "add", "Alice Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Alice Book", "--owner", "alice"])
|
||||||
@@ -213,7 +215,9 @@ class TestBookListCommand:
|
|||||||
assert "Alice Book" in result.output
|
assert "Alice Book" in result.output
|
||||||
assert "Bob Book" not in result.output
|
assert "Bob Book" not in result.output
|
||||||
|
|
||||||
def test_book_list_filter_by_location(self, app, cli_runner):
|
def test_book_list_filter_by_location(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test filtering books by location."""
|
"""Test filtering books by location."""
|
||||||
# Add books in different locations
|
# Add books in different locations
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -271,7 +275,7 @@ class TestBookListCommand:
|
|||||||
class TestBookSearchCommand:
|
class TestBookSearchCommand:
|
||||||
"""Test the 'hxbooks book search' command."""
|
"""Test the 'hxbooks book search' command."""
|
||||||
|
|
||||||
def test_book_search_basic(self, app, cli_runner):
|
def test_book_search_basic(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test basic book search functionality."""
|
"""Test basic book search functionality."""
|
||||||
# Add test books
|
# Add test books
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -309,14 +313,14 @@ class TestBookSearchCommand:
|
|||||||
assert "The Hobbit" in result.output
|
assert "The Hobbit" in result.output
|
||||||
assert "Dune" not in result.output
|
assert "Dune" not in result.output
|
||||||
|
|
||||||
def test_book_search_no_results(self, app, cli_runner):
|
def test_book_search_no_results(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test search with no matching results."""
|
"""Test search with no matching results."""
|
||||||
result = cli_runner.invoke(cli, ["book", "search", "nonexistent"])
|
result = cli_runner.invoke(cli, ["book", "search", "nonexistent"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No books found." in result.output
|
assert "No books found." in result.output
|
||||||
|
|
||||||
def test_book_search_json_format(self, app, cli_runner):
|
def test_book_search_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test book search with JSON output."""
|
"""Test book search with JSON output."""
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
cli,
|
cli,
|
||||||
@@ -378,8 +382,8 @@ class TestBookSearchCommand:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_book_search_advanced_queries(
|
def test_book_search_advanced_queries(
|
||||||
self, app, cli_runner, query, expected_titles
|
self, app: Flask, cli_runner: CliRunner, query: str, expected_titles: list[str]
|
||||||
):
|
) -> None:
|
||||||
"""Test advanced search queries with various field filters."""
|
"""Test advanced search queries with various field filters."""
|
||||||
# Set up comprehensive test data
|
# Set up comprehensive test data
|
||||||
self._setup_search_test_data(app, cli_runner)
|
self._setup_search_test_data(app, cli_runner)
|
||||||
@@ -400,7 +404,7 @@ class TestBookSearchCommand:
|
|||||||
f"Query '{query}' expected {expected_titles}, got {actual_titles}"
|
f"Query '{query}' expected {expected_titles}, got {actual_titles}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _setup_search_test_data(self, app, cli_runner):
|
def _setup_search_test_data(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Set up comprehensive test data for advanced search testing."""
|
"""Set up comprehensive test data for advanced search testing."""
|
||||||
# Book 1: The Hobbit - Fantasy, high rating, shelf 1, home
|
# Book 1: The Hobbit - Fantasy, high rating, shelf 1, home
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -517,7 +521,8 @@ class TestBookSearchCommand:
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Get reading session IDs
|
# Get reading session IDs
|
||||||
readings = (
|
readings = (
|
||||||
db.session.execute(db.select(Reading).order_by(Reading.id))
|
db.session
|
||||||
|
.execute(db.select(Reading).order_by(Reading.id))
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -539,16 +544,20 @@ class TestBookSearchCommand:
|
|||||||
# Update one book with bought_date for date filter testing
|
# Update one book with bought_date for date filter testing
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
prog_book = db.session.get(Book, prog_id)
|
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.bought_date = date(2025, 12, 1) # Before 2026-01-01
|
||||||
prog_book.first_published = 2000
|
prog_book.first_published = 2000
|
||||||
|
|
||||||
hobbit_book = db.session.get(Book, hobbit_id)
|
hobbit_book = db.session.get(Book, hobbit_id)
|
||||||
|
assert hobbit_book is not None
|
||||||
hobbit_book.first_published = 1937
|
hobbit_book.first_published = 1937
|
||||||
|
|
||||||
fellowship_book = db.session.get(Book, fellowship_id)
|
fellowship_book = db.session.get(Book, fellowship_id)
|
||||||
|
assert fellowship_book is not None
|
||||||
fellowship_book.first_published = 1954
|
fellowship_book.first_published = 1954
|
||||||
|
|
||||||
dune_book = db.session.get(Book, dune_id)
|
dune_book = db.session.get(Book, dune_id)
|
||||||
|
assert dune_book is not None
|
||||||
dune_book.first_published = 1965
|
dune_book.first_published = 1965
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -557,7 +566,7 @@ class TestBookSearchCommand:
|
|||||||
class TestReadingCommands:
|
class TestReadingCommands:
|
||||||
"""Test reading-related CLI commands."""
|
"""Test reading-related CLI commands."""
|
||||||
|
|
||||||
def test_reading_start_basic(self, app, cli_runner):
|
def test_reading_start_basic(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test starting a reading session."""
|
"""Test starting a reading session."""
|
||||||
# Add a book first
|
# Add a book first
|
||||||
result = cli_runner.invoke(
|
result = cli_runner.invoke(
|
||||||
@@ -566,7 +575,6 @@ class TestReadingCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# Extract book ID from output
|
# Extract book ID from output
|
||||||
import re
|
|
||||||
|
|
||||||
book_id_match = re.search(r"ID: (\d+)", result.output)
|
book_id_match = re.search(r"ID: (\d+)", result.output)
|
||||||
assert book_id_match
|
assert book_id_match
|
||||||
@@ -578,10 +586,12 @@ class TestReadingCommands:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert f"Started reading session" in result.output
|
assert "Started reading session" in result.output
|
||||||
assert f"for book {book_id}" in result.output
|
assert f"for book {book_id}" in result.output
|
||||||
|
|
||||||
def test_reading_finish_with_rating(self, app, cli_runner):
|
def test_reading_finish_with_rating(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test finishing a reading session with rating."""
|
"""Test finishing a reading session with rating."""
|
||||||
# Add book and start reading
|
# Add book and start reading
|
||||||
cli_runner.invoke(cli, ["book", "add", "Test Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Test Book", "--owner", "alice"])
|
||||||
@@ -597,7 +607,6 @@ class TestReadingCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# Extract reading session ID
|
# Extract reading session ID
|
||||||
import re
|
|
||||||
|
|
||||||
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
|
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
|
||||||
assert reading_id_match
|
assert reading_id_match
|
||||||
@@ -621,7 +630,7 @@ class TestReadingCommands:
|
|||||||
assert "Finished reading: Test Book" in result.output
|
assert "Finished reading: Test Book" in result.output
|
||||||
assert "Rating: 4/5" in result.output
|
assert "Rating: 4/5" in result.output
|
||||||
|
|
||||||
def test_reading_drop(self, app, cli_runner):
|
def test_reading_drop(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test dropping a reading session."""
|
"""Test dropping a reading session."""
|
||||||
# Add book and start reading
|
# Add book and start reading
|
||||||
cli_runner.invoke(cli, ["book", "add", "Boring Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Boring Book", "--owner", "alice"])
|
||||||
@@ -634,9 +643,8 @@ class TestReadingCommands:
|
|||||||
cli, ["reading", "start", str(book_id), "--owner", "alice"]
|
cli, ["reading", "start", str(book_id), "--owner", "alice"]
|
||||||
)
|
)
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
|
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)
|
reading_id = reading_id_match.group(1)
|
||||||
|
|
||||||
# Drop the reading
|
# Drop the reading
|
||||||
@@ -647,7 +655,7 @@ class TestReadingCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Dropped reading: Boring Book" in result.output
|
assert "Dropped reading: Boring Book" in result.output
|
||||||
|
|
||||||
def test_reading_list_current(self, app, cli_runner):
|
def test_reading_list_current(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing current (unfinished) readings."""
|
"""Test listing current (unfinished) readings."""
|
||||||
# Add book and start reading
|
# Add book and start reading
|
||||||
cli_runner.invoke(cli, ["book", "add", "Current Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Current Book", "--owner", "alice"])
|
||||||
@@ -666,7 +674,7 @@ class TestReadingCommands:
|
|||||||
assert "Current Book" in result.output
|
assert "Current Book" in result.output
|
||||||
assert "Reading" in result.output
|
assert "Reading" in result.output
|
||||||
|
|
||||||
def test_reading_list_json_format(self, app, cli_runner):
|
def test_reading_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing readings in JSON format."""
|
"""Test listing readings in JSON format."""
|
||||||
# Add book and start reading
|
# Add book and start reading
|
||||||
cli_runner.invoke(cli, ["book", "add", "JSON Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "JSON Book", "--owner", "alice"])
|
||||||
@@ -692,7 +700,7 @@ class TestReadingCommands:
|
|||||||
class TestWishlistCommands:
|
class TestWishlistCommands:
|
||||||
"""Test wishlist-related CLI commands."""
|
"""Test wishlist-related CLI commands."""
|
||||||
|
|
||||||
def test_wishlist_add(self, app, cli_runner):
|
def test_wishlist_add(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test adding a book to wishlist."""
|
"""Test adding a book to wishlist."""
|
||||||
# Add a book first
|
# Add a book first
|
||||||
cli_runner.invoke(cli, ["book", "add", "Desired Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Desired Book", "--owner", "alice"])
|
||||||
@@ -708,7 +716,7 @@ class TestWishlistCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Added 'Desired Book' to wishlist" in result.output
|
assert "Added 'Desired Book' to wishlist" in result.output
|
||||||
|
|
||||||
def test_wishlist_remove(self, app, cli_runner):
|
def test_wishlist_remove(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test removing a book from wishlist."""
|
"""Test removing a book from wishlist."""
|
||||||
# Add book and add to wishlist
|
# Add book and add to wishlist
|
||||||
cli_runner.invoke(cli, ["book", "add", "Unwanted Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Unwanted Book", "--owner", "alice"])
|
||||||
@@ -726,7 +734,9 @@ class TestWishlistCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert f"Removed book {book_id} from wishlist" in result.output
|
assert f"Removed book {book_id} from wishlist" in result.output
|
||||||
|
|
||||||
def test_wishlist_remove_not_in_list(self, app, cli_runner):
|
def test_wishlist_remove_not_in_list(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test removing a book that's not in wishlist."""
|
"""Test removing a book that's not in wishlist."""
|
||||||
# Add book but don't add to wishlist
|
# Add book but don't add to wishlist
|
||||||
cli_runner.invoke(cli, ["book", "add", "Not Wished Book", "--owner", "alice"])
|
cli_runner.invoke(cli, ["book", "add", "Not Wished Book", "--owner", "alice"])
|
||||||
@@ -742,14 +752,14 @@ class TestWishlistCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert f"Book {book_id} was not in wishlist" in result.output
|
assert f"Book {book_id} was not in wishlist" in result.output
|
||||||
|
|
||||||
def test_wishlist_list_empty(self, app, cli_runner):
|
def test_wishlist_list_empty(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing empty wishlist."""
|
"""Test listing empty wishlist."""
|
||||||
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
|
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Wishlist is empty." in result.output
|
assert "Wishlist is empty." in result.output
|
||||||
|
|
||||||
def test_wishlist_list_with_items(self, app, cli_runner):
|
def test_wishlist_list_with_items(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing wishlist with items."""
|
"""Test listing wishlist with items."""
|
||||||
# Add books and add to wishlist
|
# Add books and add to wishlist
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -793,7 +803,7 @@ class TestWishlistCommands:
|
|||||||
assert "Author One" in result.output
|
assert "Author One" in result.output
|
||||||
assert "Author Two" in result.output
|
assert "Author Two" in result.output
|
||||||
|
|
||||||
def test_wishlist_list_json_format(self, app, cli_runner):
|
def test_wishlist_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test listing wishlist in JSON format."""
|
"""Test listing wishlist in JSON format."""
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
cli,
|
cli,
|
||||||
@@ -829,14 +839,14 @@ class TestWishlistCommands:
|
|||||||
class TestDatabaseCommands:
|
class TestDatabaseCommands:
|
||||||
"""Test database management CLI commands."""
|
"""Test database management CLI commands."""
|
||||||
|
|
||||||
def test_db_init(self, app, cli_runner):
|
def test_db_init(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test database initialization."""
|
"""Test database initialization."""
|
||||||
result = cli_runner.invoke(cli, ["db", "init"])
|
result = cli_runner.invoke(cli, ["db", "init"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Database initialized." in result.output
|
assert "Database initialized." in result.output
|
||||||
|
|
||||||
def test_db_seed(self, app, cli_runner):
|
def test_db_seed(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test database seeding with sample data."""
|
"""Test database seeding with sample data."""
|
||||||
result = cli_runner.invoke(cli, ["db", "seed", "--owner", "test_owner"])
|
result = cli_runner.invoke(cli, ["db", "seed", "--owner", "test_owner"])
|
||||||
|
|
||||||
@@ -855,7 +865,7 @@ class TestDatabaseCommands:
|
|||||||
assert "Dune" in titles
|
assert "Dune" in titles
|
||||||
assert "The Pragmatic Programmer" in titles
|
assert "The Pragmatic Programmer" in titles
|
||||||
|
|
||||||
def test_db_status_empty(self, app, cli_runner):
|
def test_db_status_empty(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test database status with empty database."""
|
"""Test database status with empty database."""
|
||||||
result = cli_runner.invoke(cli, ["db", "status"])
|
result = cli_runner.invoke(cli, ["db", "status"])
|
||||||
|
|
||||||
@@ -868,7 +878,7 @@ class TestDatabaseCommands:
|
|||||||
assert "Reading sessions: 0" in result.output
|
assert "Reading sessions: 0" in result.output
|
||||||
assert "Wishlist items: 0" in result.output
|
assert "Wishlist items: 0" in result.output
|
||||||
|
|
||||||
def test_db_status_with_data(self, app, cli_runner):
|
def test_db_status_with_data(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
"""Test database status with sample data."""
|
"""Test database status with sample data."""
|
||||||
# Add some test data
|
# Add some test data
|
||||||
cli_runner.invoke(
|
cli_runner.invoke(
|
||||||
@@ -908,21 +918,27 @@ class TestDatabaseCommands:
|
|||||||
class TestErrorScenarios:
|
class TestErrorScenarios:
|
||||||
"""Test error handling and edge cases."""
|
"""Test error handling and edge cases."""
|
||||||
|
|
||||||
def test_reading_start_invalid_book_id(self, app, cli_runner):
|
def test_reading_start_invalid_book_id(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test starting reading with non-existent book ID."""
|
"""Test starting reading with non-existent book ID."""
|
||||||
result = cli_runner.invoke(cli, ["reading", "start", "999", "--owner", "alice"])
|
result = cli_runner.invoke(cli, ["reading", "start", "999", "--owner", "alice"])
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Error starting reading:" in result.output
|
assert "Error starting reading:" in result.output
|
||||||
|
|
||||||
def test_wishlist_add_invalid_book_id(self, app, cli_runner):
|
def test_wishlist_add_invalid_book_id(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test adding non-existent book to wishlist."""
|
"""Test adding non-existent book to wishlist."""
|
||||||
result = cli_runner.invoke(cli, ["wishlist", "add", "999", "--owner", "alice"])
|
result = cli_runner.invoke(cli, ["wishlist", "add", "999", "--owner", "alice"])
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Error adding to wishlist:" in result.output
|
assert "Error adding to wishlist:" in result.output
|
||||||
|
|
||||||
def test_reading_finish_invalid_reading_id(self, app, cli_runner):
|
def test_reading_finish_invalid_reading_id(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
"""Test finishing non-existent reading session."""
|
"""Test finishing non-existent reading session."""
|
||||||
result = cli_runner.invoke(cli, ["reading", "finish", "999"])
|
result = cli_runner.invoke(cli, ["reading", "finish", "999"])
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,15 @@ field filters, and edge case handling.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from hxbooks.search import (
|
from hxbooks.search import (
|
||||||
ComparisonOperator,
|
ComparisonOperator,
|
||||||
Field,
|
Field,
|
||||||
FieldFilter,
|
|
||||||
QueryParser,
|
QueryParser,
|
||||||
SearchQuery,
|
SearchQuery,
|
||||||
|
_convert_value, # noqa: PLC2701
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,31 +27,31 @@ def parser() -> QueryParser:
|
|||||||
class TestQueryParser:
|
class TestQueryParser:
|
||||||
"""Test the QueryParser class functionality."""
|
"""Test the QueryParser class functionality."""
|
||||||
|
|
||||||
def test_parse_empty_query(self, parser: QueryParser):
|
def test_parse_empty_query(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing an empty query string."""
|
"""Test parsing an empty query string."""
|
||||||
result = parser.parse("")
|
result = parser.parse("")
|
||||||
assert result.text_terms == []
|
assert result.text_terms == []
|
||||||
assert result.field_filters == []
|
assert result.field_filters == []
|
||||||
|
|
||||||
def test_parse_whitespace_only(self, parser: QueryParser):
|
def test_parse_whitespace_only(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing a query with only whitespace."""
|
"""Test parsing a query with only whitespace."""
|
||||||
result = parser.parse(" \t\n ")
|
result = parser.parse(" \t\n ")
|
||||||
assert result.text_terms == []
|
assert result.text_terms == []
|
||||||
assert result.field_filters == []
|
assert result.field_filters == []
|
||||||
|
|
||||||
def test_parse_simple_text_terms(self, parser: QueryParser):
|
def test_parse_simple_text_terms(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing simple text search terms."""
|
"""Test parsing simple text search terms."""
|
||||||
result = parser.parse("hobbit tolkien")
|
result = parser.parse("hobbit tolkien")
|
||||||
assert result.text_terms == ["hobbit", "tolkien"]
|
assert result.text_terms == ["hobbit", "tolkien"]
|
||||||
assert result.field_filters == []
|
assert result.field_filters == []
|
||||||
|
|
||||||
def test_parse_quoted_text_terms(self, parser: QueryParser):
|
def test_parse_quoted_text_terms(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing quoted text search terms."""
|
"""Test parsing quoted text search terms."""
|
||||||
result = parser.parse('"the hobbit" tolkien')
|
result = parser.parse('"the hobbit" tolkien')
|
||||||
assert result.text_terms == ["the hobbit", "tolkien"]
|
assert result.text_terms == ["the hobbit", "tolkien"]
|
||||||
assert result.field_filters == []
|
assert result.field_filters == []
|
||||||
|
|
||||||
def test_parse_quoted_text_with_spaces(self, parser: QueryParser):
|
def test_parse_quoted_text_with_spaces(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing quoted text containing multiple spaces."""
|
"""Test parsing quoted text containing multiple spaces."""
|
||||||
result = parser.parse('"lord of the rings"')
|
result = parser.parse('"lord of the rings"')
|
||||||
assert result.text_terms == ["lord of the rings"]
|
assert result.text_terms == ["lord of the rings"]
|
||||||
@@ -62,7 +61,7 @@ class TestQueryParser:
|
|||||||
class TestFieldFilters:
|
class TestFieldFilters:
|
||||||
"""Test field filter parsing."""
|
"""Test field filter parsing."""
|
||||||
|
|
||||||
def test_parse_title_filter(self, parser: QueryParser):
|
def test_parse_title_filter(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing title field filter."""
|
"""Test parsing title field filter."""
|
||||||
result = parser.parse("title:hobbit")
|
result = parser.parse("title:hobbit")
|
||||||
assert len(result.field_filters) == 1
|
assert len(result.field_filters) == 1
|
||||||
@@ -72,7 +71,7 @@ class TestFieldFilters:
|
|||||||
assert filter.value == "hobbit"
|
assert filter.value == "hobbit"
|
||||||
assert filter.negated is False
|
assert filter.negated is False
|
||||||
|
|
||||||
def test_parse_quoted_title_filter(self, parser: QueryParser):
|
def test_parse_quoted_title_filter(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing quoted title field filter."""
|
"""Test parsing quoted title field filter."""
|
||||||
result = parser.parse('title:"the hobbit"')
|
result = parser.parse('title:"the hobbit"')
|
||||||
assert len(result.field_filters) == 1
|
assert len(result.field_filters) == 1
|
||||||
@@ -80,7 +79,7 @@ class TestFieldFilters:
|
|||||||
assert filter.field == Field.TITLE
|
assert filter.field == Field.TITLE
|
||||||
assert filter.value == "the hobbit"
|
assert filter.value == "the hobbit"
|
||||||
|
|
||||||
def test_parse_author_filter(self, parser: QueryParser):
|
def test_parse_author_filter(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing author field filter."""
|
"""Test parsing author field filter."""
|
||||||
result = parser.parse("author:tolkien")
|
result = parser.parse("author:tolkien")
|
||||||
assert len(result.field_filters) == 1
|
assert len(result.field_filters) == 1
|
||||||
@@ -88,7 +87,7 @@ class TestFieldFilters:
|
|||||||
assert filter.field == Field.AUTHOR
|
assert filter.field == Field.AUTHOR
|
||||||
assert filter.value == "tolkien"
|
assert filter.value == "tolkien"
|
||||||
|
|
||||||
def test_parse_negated_filter(self, parser: QueryParser):
|
def test_parse_negated_filter(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing negated field filter."""
|
"""Test parsing negated field filter."""
|
||||||
result = parser.parse("-genre:romance")
|
result = parser.parse("-genre:romance")
|
||||||
assert len(result.field_filters) == 1
|
assert len(result.field_filters) == 1
|
||||||
@@ -97,7 +96,7 @@ class TestFieldFilters:
|
|||||||
assert filter.value == "romance"
|
assert filter.value == "romance"
|
||||||
assert filter.negated is True
|
assert filter.negated is True
|
||||||
|
|
||||||
def test_parse_multiple_filters(self, parser: QueryParser):
|
def test_parse_multiple_filters(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing multiple field filters."""
|
"""Test parsing multiple field filters."""
|
||||||
result = parser.parse("author:tolkien genre:fantasy")
|
result = parser.parse("author:tolkien genre:fantasy")
|
||||||
assert len(result.field_filters) == 2
|
assert len(result.field_filters) == 2
|
||||||
@@ -108,7 +107,7 @@ class TestFieldFilters:
|
|||||||
genre_filter = next(f for f in result.field_filters if f.field == Field.GENRE)
|
genre_filter = next(f for f in result.field_filters if f.field == Field.GENRE)
|
||||||
assert genre_filter.value == "fantasy"
|
assert genre_filter.value == "fantasy"
|
||||||
|
|
||||||
def test_parse_mixed_filters_and_text(self, parser: QueryParser):
|
def test_parse_mixed_filters_and_text(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing mix of field filters and text terms."""
|
"""Test parsing mix of field filters and text terms."""
|
||||||
result = parser.parse('epic author:tolkien "middle earth"')
|
result = parser.parse('epic author:tolkien "middle earth"')
|
||||||
assert "epic" in result.text_terms
|
assert "epic" in result.text_terms
|
||||||
@@ -133,8 +132,11 @@ class TestComparisonOperators:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_comparison_operators(
|
def test_parse_comparison_operators(
|
||||||
self, parser: QueryParser, operator_str, expected_operator
|
self,
|
||||||
):
|
parser: QueryParser,
|
||||||
|
operator_str: str,
|
||||||
|
expected_operator: ComparisonOperator,
|
||||||
|
) -> None:
|
||||||
"""Test parsing all supported comparison operators."""
|
"""Test parsing all supported comparison operators."""
|
||||||
query = f"rating{operator_str}4"
|
query = f"rating{operator_str}4"
|
||||||
result = parser.parse(query)
|
result = parser.parse(query)
|
||||||
@@ -145,7 +147,7 @@ class TestComparisonOperators:
|
|||||||
assert filter.operator == expected_operator
|
assert filter.operator == expected_operator
|
||||||
assert filter.value == 4
|
assert filter.value == 4
|
||||||
|
|
||||||
def test_parse_date_comparison(self, parser: QueryParser):
|
def test_parse_date_comparison(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing date comparison operators."""
|
"""Test parsing date comparison operators."""
|
||||||
result = parser.parse("added>=2026-03-15")
|
result = parser.parse("added>=2026-03-15")
|
||||||
assert len(result.field_filters) == 1
|
assert len(result.field_filters) == 1
|
||||||
@@ -154,7 +156,7 @@ class TestComparisonOperators:
|
|||||||
assert filter.operator == ComparisonOperator.GREATER_EQUAL
|
assert filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
assert filter.value == date(2026, 3, 15)
|
assert filter.value == date(2026, 3, 15)
|
||||||
|
|
||||||
def test_parse_numeric_comparison(self, parser: QueryParser):
|
def test_parse_numeric_comparison(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing numeric comparison operators."""
|
"""Test parsing numeric comparison operators."""
|
||||||
result = parser.parse("shelf>2")
|
result = parser.parse("shelf>2")
|
||||||
assert len(result.field_filters) == 1
|
assert len(result.field_filters) == 1
|
||||||
@@ -167,79 +169,77 @@ class TestComparisonOperators:
|
|||||||
class TestTypeConversion:
|
class TestTypeConversion:
|
||||||
"""Test the _convert_value method for different field types."""
|
"""Test the _convert_value method for different field types."""
|
||||||
|
|
||||||
def test_convert_date_field_valid(self, parser: QueryParser):
|
def test_convert_date_field_valid(self, parser: QueryParser) -> None:
|
||||||
"""Test converting valid date strings for date fields."""
|
"""Test converting valid date strings for date fields."""
|
||||||
result = parser._convert_value(Field.BOUGHT_DATE, "2026-03-15")
|
result = _convert_value(Field.BOUGHT_DATE, "2026-03-15")
|
||||||
assert result == date(2026, 3, 15)
|
assert result == date(2026, 3, 15)
|
||||||
|
|
||||||
result = parser._convert_value(Field.READ_DATE, "2025-12-31")
|
result = _convert_value(Field.READ_DATE, "2025-12-31")
|
||||||
assert result == date(2025, 12, 31)
|
assert result == date(2025, 12, 31)
|
||||||
|
|
||||||
result = parser._convert_value(Field.ADDED_DATE, "2024-01-01")
|
result = _convert_value(Field.ADDED_DATE, "2024-01-01")
|
||||||
assert result == date(2024, 1, 1)
|
assert result == date(2024, 1, 1)
|
||||||
|
|
||||||
def test_convert_date_field_invalid(self, parser: QueryParser):
|
def test_convert_date_field_invalid(self, parser: QueryParser) -> None:
|
||||||
"""Test converting invalid date strings falls back to string."""
|
"""Test converting invalid date strings falls back to string."""
|
||||||
result = parser._convert_value(Field.BOUGHT_DATE, "invalid-date")
|
result = _convert_value(Field.BOUGHT_DATE, "invalid-date")
|
||||||
assert result == "invalid-date"
|
assert result == "invalid-date"
|
||||||
|
|
||||||
result = parser._convert_value(
|
result = _convert_value(Field.READ_DATE, "2026-13-45") # Invalid month/day
|
||||||
Field.READ_DATE, "2026-13-45"
|
|
||||||
) # Invalid month/day
|
|
||||||
assert result == "2026-13-45"
|
assert result == "2026-13-45"
|
||||||
|
|
||||||
result = parser._convert_value(Field.ADDED_DATE, "not-a-date")
|
result = _convert_value(Field.ADDED_DATE, "not-a-date")
|
||||||
assert result == "not-a-date"
|
assert result == "not-a-date"
|
||||||
|
|
||||||
def test_convert_numeric_field_integers(self, parser: QueryParser):
|
def test_convert_numeric_field_integers(self, parser: QueryParser) -> None:
|
||||||
"""Test converting integer strings for numeric fields."""
|
"""Test converting integer strings for numeric fields."""
|
||||||
result = parser._convert_value(Field.RATING, "5")
|
result = _convert_value(Field.RATING, "5")
|
||||||
assert result == 5
|
assert result == 5
|
||||||
assert isinstance(result, int)
|
assert isinstance(result, int)
|
||||||
|
|
||||||
result = parser._convert_value(Field.SHELF, "10")
|
result = _convert_value(Field.SHELF, "10")
|
||||||
assert result == 10
|
assert result == 10
|
||||||
|
|
||||||
result = parser._convert_value(Field.YEAR, "2026")
|
result = _convert_value(Field.YEAR, "2026")
|
||||||
assert result == 2026
|
assert result == 2026
|
||||||
|
|
||||||
def test_convert_numeric_field_floats(self, parser: QueryParser):
|
def test_convert_numeric_field_floats(self, parser: QueryParser) -> None:
|
||||||
"""Test converting float strings for numeric fields."""
|
"""Test converting float strings for numeric fields."""
|
||||||
result = parser._convert_value(Field.RATING, "4.5")
|
result = _convert_value(Field.RATING, "4.5")
|
||||||
assert result == 4.5
|
assert result == pytest.approx(4.5)
|
||||||
assert isinstance(result, float)
|
assert isinstance(result, float)
|
||||||
|
|
||||||
result = parser._convert_value(Field.SHELF, "2.0")
|
result = _convert_value(Field.SHELF, "2.0")
|
||||||
assert result == 2.0
|
assert result == pytest.approx(2.0)
|
||||||
|
|
||||||
def test_convert_numeric_field_invalid(self, parser: QueryParser):
|
def test_convert_numeric_field_invalid(self, parser: QueryParser) -> None:
|
||||||
"""Test converting invalid numeric strings falls back to string."""
|
"""Test converting invalid numeric strings falls back to string."""
|
||||||
result = parser._convert_value(Field.RATING, "not-a-number")
|
result = _convert_value(Field.RATING, "not-a-number")
|
||||||
assert result == "not-a-number"
|
assert result == "not-a-number"
|
||||||
|
|
||||||
result = parser._convert_value(Field.SHELF, "abc")
|
result = _convert_value(Field.SHELF, "abc")
|
||||||
assert result == "abc"
|
assert result == "abc"
|
||||||
|
|
||||||
result = parser._convert_value(Field.YEAR, "twenty-twenty-six")
|
result = _convert_value(Field.YEAR, "twenty-twenty-six")
|
||||||
assert result == "twenty-twenty-six"
|
assert result == "twenty-twenty-six"
|
||||||
|
|
||||||
def test_convert_string_fields(self, parser: QueryParser):
|
def test_convert_string_fields(self, parser: QueryParser) -> None:
|
||||||
"""Test converting values for string fields returns as-is."""
|
"""Test converting values for string fields returns as-is."""
|
||||||
result = parser._convert_value(Field.TITLE, "The Hobbit")
|
result = _convert_value(Field.TITLE, "The Hobbit")
|
||||||
assert result == "The Hobbit"
|
assert result == "The Hobbit"
|
||||||
|
|
||||||
result = parser._convert_value(Field.AUTHOR, "Tolkien")
|
result = _convert_value(Field.AUTHOR, "Tolkien")
|
||||||
assert result == "Tolkien"
|
assert result == "Tolkien"
|
||||||
|
|
||||||
result = parser._convert_value(Field.GENRE, "Fantasy")
|
result = _convert_value(Field.GENRE, "Fantasy")
|
||||||
assert result == "Fantasy"
|
assert result == "Fantasy"
|
||||||
|
|
||||||
# Even things that look like dates/numbers should stay as strings for string fields
|
# Even things that look like dates/numbers should stay as strings for string fields
|
||||||
result = parser._convert_value(Field.TITLE, "2026-03-15")
|
result = _convert_value(Field.TITLE, "2026-03-15")
|
||||||
assert result == "2026-03-15"
|
assert result == "2026-03-15"
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
result = parser._convert_value(Field.AUTHOR, "123")
|
result = _convert_value(Field.AUTHOR, "123")
|
||||||
assert result == "123"
|
assert result == "123"
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
@@ -247,13 +247,13 @@ class TestTypeConversion:
|
|||||||
class TestParsingEdgeCases:
|
class TestParsingEdgeCases:
|
||||||
"""Test edge cases and error handling in query parsing."""
|
"""Test edge cases and error handling in query parsing."""
|
||||||
|
|
||||||
def test_parse_invalid_field_name(self, parser: QueryParser):
|
def test_parse_invalid_field_name(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing with invalid field names falls back to text search."""
|
"""Test parsing with invalid field names falls back to text search."""
|
||||||
result = parser.parse("invalid_field:value")
|
result = parser.parse("invalid_field:value")
|
||||||
# Should fall back to treating the whole thing as text
|
# Should fall back to treating the whole thing as text
|
||||||
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
|
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
|
||||||
|
|
||||||
def test_parse_mixed_quotes_and_operators(self, parser: QueryParser):
|
def test_parse_mixed_quotes_and_operators(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing complex queries with quotes and operators."""
|
"""Test parsing complex queries with quotes and operators."""
|
||||||
result = parser.parse('title:"The Lord" author:tolkien rating>=4')
|
result = parser.parse('title:"The Lord" author:tolkien rating>=4')
|
||||||
|
|
||||||
@@ -278,35 +278,36 @@ class TestParsingEdgeCases:
|
|||||||
assert rating_filter.value == 4
|
assert rating_filter.value == 4
|
||||||
assert rating_filter.operator == ComparisonOperator.GREATER_EQUAL
|
assert rating_filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
|
||||||
def test_parse_escaped_quotes(self, parser: QueryParser):
|
def test_parse_escaped_quotes(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing strings with escaped quotes."""
|
"""Test parsing strings with escaped quotes."""
|
||||||
result = parser.parse(r'title:"She said \"hello\""')
|
result = parser.parse(r'title:"She said \"hello\""')
|
||||||
if result.field_filters:
|
if result.field_filters:
|
||||||
# If parsing succeeds, check the escaped quote handling
|
# If parsing succeeds, check the escaped quote handling
|
||||||
filter = result.field_filters[0]
|
filter = result.field_filters[0]
|
||||||
|
assert isinstance(filter.value, str)
|
||||||
assert "hello" in filter.value
|
assert "hello" in filter.value
|
||||||
# If parsing fails, it should fall back gracefully
|
# If parsing fails, it should fall back gracefully
|
||||||
|
|
||||||
def test_parse_special_characters(self, parser: QueryParser):
|
def test_parse_special_characters(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing queries with special characters."""
|
"""Test parsing queries with special characters."""
|
||||||
result = parser.parse("title:C++ author:Stroustrup")
|
result = parser.parse("title:C++ author:Stroustrup")
|
||||||
# Should handle the + characters gracefully
|
# Should handle the + characters gracefully
|
||||||
assert len(result.field_filters) >= 1 or len(result.text_terms) >= 1
|
assert len(result.field_filters) >= 1 or len(result.text_terms) >= 1
|
||||||
|
|
||||||
def test_parse_very_long_query(self, parser: QueryParser):
|
def test_parse_very_long_query(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing very long query strings."""
|
"""Test parsing very long query strings."""
|
||||||
long_value = "a" * 1000
|
long_value = "a" * 1000
|
||||||
result = parser.parse(f"title:{long_value}")
|
result = parser.parse(f"title:{long_value}")
|
||||||
# Should handle long strings without crashing
|
# Should handle long strings without crashing
|
||||||
assert isinstance(result, SearchQuery)
|
assert isinstance(result, SearchQuery)
|
||||||
|
|
||||||
def test_parse_unicode_characters(self, parser: QueryParser):
|
def test_parse_unicode_characters(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing queries with unicode characters."""
|
"""Test parsing queries with unicode characters."""
|
||||||
result = parser.parse("title:Café author:José")
|
result = parser.parse("title:Café author:José")
|
||||||
# Should handle unicode gracefully
|
# Should handle unicode gracefully
|
||||||
assert isinstance(result, SearchQuery)
|
assert isinstance(result, SearchQuery)
|
||||||
|
|
||||||
def test_fallback_behavior_on_parse_error(self, parser: QueryParser):
|
def test_fallback_behavior_on_parse_error(self, parser: QueryParser) -> None:
|
||||||
"""Test that invalid syntax falls back to text search."""
|
"""Test that invalid syntax falls back to text search."""
|
||||||
# Construct a query that should cause parse errors
|
# Construct a query that should cause parse errors
|
||||||
invalid_queries = [
|
invalid_queries = [
|
||||||
@@ -327,7 +328,7 @@ class TestParsingEdgeCases:
|
|||||||
class TestComplexQueries:
|
class TestComplexQueries:
|
||||||
"""Test parsing of complex, real-world query examples."""
|
"""Test parsing of complex, real-world query examples."""
|
||||||
|
|
||||||
def test_parse_realistic_book_search(self, parser: QueryParser):
|
def test_parse_realistic_book_search(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing realistic book search queries."""
|
"""Test parsing realistic book search queries."""
|
||||||
result = parser.parse(
|
result = parser.parse(
|
||||||
'author:tolkien genre:fantasy -genre:romance rating>=4 "middle earth"'
|
'author:tolkien genre:fantasy -genre:romance rating>=4 "middle earth"'
|
||||||
@@ -363,7 +364,7 @@ class TestComplexQueries:
|
|||||||
assert romance_filter.value == "romance"
|
assert romance_filter.value == "romance"
|
||||||
assert romance_filter.negated is True
|
assert romance_filter.negated is True
|
||||||
|
|
||||||
def test_parse_location_and_date_filters(self, parser: QueryParser):
|
def test_parse_location_and_date_filters(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing location and date-based queries."""
|
"""Test parsing location and date-based queries."""
|
||||||
result = parser.parse("place:home bookshelf:fantasy shelf>=2 added>=2026-01-01")
|
result = parser.parse("place:home bookshelf:fantasy shelf>=2 added>=2026-01-01")
|
||||||
|
|
||||||
@@ -372,21 +373,24 @@ class TestComplexQueries:
|
|||||||
place_filter = next(
|
place_filter = next(
|
||||||
(f for f in result.field_filters if f.field == Field.PLACE), None
|
(f for f in result.field_filters if f.field == Field.PLACE), None
|
||||||
)
|
)
|
||||||
|
assert place_filter is not None
|
||||||
assert place_filter.value == "home"
|
assert place_filter.value == "home"
|
||||||
|
|
||||||
shelf_filter = next(
|
shelf_filter = next(
|
||||||
(f for f in result.field_filters if f.field == Field.SHELF), None
|
(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.value == 2
|
||||||
assert shelf_filter.operator == ComparisonOperator.GREATER_EQUAL
|
assert shelf_filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
|
||||||
added_filter = next(
|
added_filter = next(
|
||||||
(f for f in result.field_filters if f.field == Field.ADDED_DATE), None
|
(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.value == date(2026, 1, 1)
|
||||||
assert added_filter.operator == ComparisonOperator.GREATER_EQUAL
|
assert added_filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
|
||||||
def test_parse_mixed_types_comprehensive(self, parser: QueryParser):
|
def test_parse_mixed_types_comprehensive(self, parser: QueryParser) -> None:
|
||||||
"""Test parsing query with all major field types."""
|
"""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'
|
query = 'title:"Complex Book" author:Author year=2020 rating>=4 bought<=2025-12-31 -genre:boring epic adventure'
|
||||||
result = parser.parse(query)
|
result = parser.parse(query)
|
||||||
|
|||||||
191
uv.lock
generated
191
uv.lock
generated
@@ -43,6 +43,15 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.5"
|
version = "3.4.5"
|
||||||
@@ -89,6 +98,24 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "flask"
|
name = "flask"
|
||||||
version = "3.1.3"
|
version = "3.1.3"
|
||||||
@@ -195,11 +222,18 @@ dependencies = [
|
|||||||
{ name = "jinja2-fragments" },
|
{ name = "jinja2-fragments" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyparsing" },
|
{ name = "pyparsing" },
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.13.0" },
|
{ name = "alembic", specifier = ">=1.13.0" },
|
||||||
@@ -212,11 +246,27 @@ requires-dist = [
|
|||||||
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
|
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
{ name = "pyparsing", specifier = ">=3.3.2" },
|
{ name = "pyparsing", specifier = ">=3.3.2" },
|
||||||
{ name = "pytest", specifier = ">=9.0.2" },
|
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.48" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@@ -310,6 +360,15 @@ wheels = [
|
|||||||
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@@ -319,6 +378,15 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -328,6 +396,22 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
@@ -416,6 +500,45 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.32.5"
|
||||||
@@ -431,6 +554,31 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.48"
|
version = "2.0.48"
|
||||||
@@ -457,6 +605,30 @@ wheels = [
|
|||||||
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
@@ -487,6 +659,21 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user