Compare commits

...
5 Commits
9 changed files with 58 additions and 52 deletions

No files matched your search

+3 -1
View File
@@ -9,7 +9,9 @@
"Bash(PYTHONPATH=. uv run *)",
"Bash(python *)",
"Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)",
"Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')"
"Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')",
"Bash(docker build *)",
"Bash(git status *)"
]
}
}
+2
View File
@@ -10,6 +10,8 @@ venv/
# Databases
*.db
*.db-shm
*.db-wal
*.sqlite3
# Test / tooling
+5 -2
View File
@@ -18,8 +18,11 @@ RUN uv sync --no-dev
# Copy the application.
COPY . .
RUN chmod +x entrypoint.sh
# If sqlite3 is used
VOLUME /data
EXPOSE 8000
# Default: run the API server. docker-compose overrides this to also seed.
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Seeds perms/roles/superuser (idempotent, no-op if already present), then runs the API server.
ENTRYPOINT ["./entrypoint.sh"]
+7
View File
@@ -22,9 +22,16 @@ migrate:
fmt:
uv run black .
clean:
rm app.db
uv run alembic upgrade head
uv run python -m scripts.seed
docker:
mkdir -p artifacts
docker build -t $(IMAGE_NAME):$(VERSION) .
docker-push: docker
docker tag $(IMAGE_NAME):$(VERSION) $(IMAGE_NAME):latest
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
+14
View File
@@ -5,6 +5,7 @@ Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -21,6 +22,19 @@ class Base(DeclarativeBase):
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
if engine.sync_engine.dialect.name == "sqlite":
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
"""WAL mode lets readers/writers avoid blocking each other; busy_timeout
makes a connection wait for a lock instead of immediately raising
"database is locked" (the pysqlite/aiosqlite default is 0ms).
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
+6 -17
View File
@@ -21,14 +21,9 @@ async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
return await db.get(ImportedPart, inventory_id)
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 500
) -> list[ImportedPart]:
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[ImportedPart]:
result = await db.execute(
select(ImportedPart)
.order_by(ImportedPart.InventoryID)
.offset(skip)
.limit(limit)
select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
)
return list(result.scalars().all())
@@ -42,9 +37,7 @@ async def list_filtered(
q = select(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
result = await db.execute(
q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
)
result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit))
return list(result.scalars().all())
@@ -85,7 +78,7 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
return 0, 0
clean = [
{k: str(v) if v is not None else None for k, v in r.items() if k in _FIELDS}
{k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS}
for r in records
if r.get("InventoryID")
]
@@ -95,9 +88,7 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute(
select(ImportedPart.InventoryID).where(
ImportedPart.InventoryID.in_(incoming_ids)
)
select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids))
)
existing_ids = {row[0] for row in result.all()}
@@ -112,9 +103,7 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals:
await db.execute(
update(ImportedPart)
.where(ImportedPart.InventoryID == inv_id)
.values(vals)
update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals)
)
await db.commit()
+14 -28
View File
@@ -52,8 +52,10 @@ MM_PER_INCH = 25.4
# e.g.
# M8-1.25 - 8mm OD, 1.25 mm pitch
# M8 x 1.25
# M50x.75 - No leading digit on floats
# Float pattern: \d*\.?\d+
_ISO_THREAD_RE = re.compile(
r"^M\s*(?P<diameter>\d+(?:\.\d+)?)\s*[xX-]\s*(?P<pitch>\d+(?:\.\d+)?)(?P<extra>.*)$"
r"^M\s*(?P<diameter>\d*\.?\d+)\s*[xX-]\s*P?(?P<pitch>\d*\.?\d+)(?P<extra>.*)$"
)
# e.g.
@@ -63,7 +65,7 @@ _ISO_THREAD_RE = re.compile(
#
# Leading digit / period, one or more digits, a dash, then
_UNC_THREAD_RE = re.compile(
r"^(?P<diameter>\d+(?:\.\d+)?(?:/\d+)?)\s*-\s*(?P<tpi>\d+)"
r"^(?P<diameter>\d*\.?\d+)\s*-\s*(?P<tpi>\d*\.?\d+)"
r"\s*(?P<series>UNEF|UNC|UNF|UNS|UN)?$",
re.IGNORECASE,
)
@@ -109,9 +111,7 @@ async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
return apn
async def _add_attachment_point(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> None:
async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: ImportedPart) -> None:
"""Infer and create an attachment point for a newly-synced AugmentedPart.
Preference order: TBXThreadAttribute (ISO/UNC thread) then ProductODmm/in
@@ -127,9 +127,7 @@ async def _add_attachment_point(
if kind == "iso":
depth_mm = _parse_float(ip.ProductMaxThreadDepthmm)
thread = await iso_thread_crud.get_by_values(
db, depth_mm=depth_mm, **fields
)
thread = await iso_thread_crud.get_by_values(db, depth_mm=depth_mm, **fields)
if thread is None:
thread = await iso_thread_crud.create(db, depth_mm=depth_mm, **fields)
await attachment_point_crud.create(
@@ -140,9 +138,7 @@ async def _add_attachment_point(
)
elif kind == "unc":
depth_in = _parse_float(ip.ProductMaxThreadDepthin)
thread = await unc_thread_crud.get_by_values(
db, depth_in=depth_in, **fields
)
thread = await unc_thread_crud.get_by_values(db, depth_in=depth_in, **fields)
if thread is None:
thread = await unc_thread_crud.create(db, depth_in=depth_in, **fields)
await attachment_point_crud.create(
@@ -201,7 +197,7 @@ async def sync_one(
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
ap = AugmentedPart(
sku=None,
sku=ip.InventoryID,
description=description,
colour_id=colour.id,
manufacturer_id=manufacturer.id,
@@ -240,9 +236,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
# IDs referenced in existing associations
result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct()
)
result = await db.execute(select(augmented_part_imported_parts.c.imported_part_id).distinct())
all_linked_ids: set[str] = {row[0] for row in result.all()}
# IDs that still exist in imported_parts
@@ -256,9 +250,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# Which AugmentedParts are affected?
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
augmented_part_imported_parts.c.imported_part_id.in_(orphaned_import_ids)
)
)
orphaned_ap_ids: set[int] = {row[0] for row in result.all()}
@@ -266,9 +258,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# Remove the stale association rows
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
augmented_part_imported_parts.c.imported_part_id.in_(orphaned_import_ids)
)
)
await db.flush()
@@ -291,9 +281,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 2
# Re-query linked IDs after phase 1 cleanup
result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct()
)
result = await db.execute(select(augmented_part_imported_parts.c.imported_part_id).distinct())
now_linked_ids: set[str] = {row[0] for row in result.all()}
unlinked_ids = current_ids - now_linked_ids
@@ -301,9 +289,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
skipped = 0
if not unlinked_ids:
return AugmentedPartSyncResult(
created=created, deleted=deleted, skipped=skipped
)
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped)
result = await db.execute(
select(ImportedPart).where(ImportedPart.InventoryID.in_(unlinked_ids))
@@ -331,7 +317,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
colour = await part_colour_crud.create(db, name=colour_name)
ap = AugmentedPart(
sku=None,
sku=ip.InventoryID,
description=description,
colour_id=colour.id,
manufacturer_id=manufacturer.id,
-4
View File
@@ -28,10 +28,6 @@ services:
depends_on:
db:
condition: service_healthy
# Seed default roles/permissions/superuser, then start the server.
command: >
sh -c "uv run python -m scripts.seed &&
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000"
volumes:
pgdata:
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -e
# Idempotent: creates perms/roles/superuser only if missing.
uv run python -m scripts.seed
exec uv run uvicorn app.main:app --host 0.0.0.0 --port 8000