Skip to content

Commit e40d594

Browse files
authored
feat(software): add unscored software category (§6.11) (#42)
* feat(software): add unscored software category (§6.11) Adds a standalone `software` category (video game sibling — no brand FK, makers as free-text developers/publishers). Model + schema + serializer + router (list/detail, sort, unscored) + main + seed + dump COLLECTIONS + validate (SOFTWARE_REQUIRED) + integration tests. Fields: release_date, developers, publishers, operating_systems, programming_languages, licenses, genres. Enables Wikidata Q7397 software (~14k) toward the 1M dataset. * ci: retrigger (setup-python runner flake) * ci(test): bump setup-python v5->v6 (fix Node 24 pip-cache stack overflow) * ci(test): drop pip cache (Node 24 stack-overflow in setup-python cache restore)
1 parent 2588281 commit e40d594

13 files changed

Lines changed: 252 additions & 2 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,9 @@ jobs:
1616
repository: GetTechAPI/TechAPI
1717
path: TechAPI
1818

19-
- uses: actions/setup-python@v5
19+
- uses: actions/setup-python@v6
2020
with:
2121
python-version: "3.12"
22-
cache: pip
2322

2423
- name: Install dependencies
2524
run: pip install -e ".[dev]"

app/dump.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"laptops",
3333
"monitors",
3434
"games",
35+
"software",
3536
]
3637
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
3738
SCORED = {"smartphones", "cpus", "gpus", "socs"}

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
monitors,
2626
smartphones,
2727
socs,
28+
software,
2829
)
2930

3031
PREFIX = settings.api_version_prefix
@@ -88,6 +89,7 @@ async def add_request_id(
8889
app.include_router(laptops.router, prefix=PREFIX)
8990
app.include_router(monitors.router, prefix=PREFIX)
9091
app.include_router(games.router, prefix=PREFIX)
92+
app.include_router(software.router, prefix=PREFIX)
9193

9294

9395
@app.get("/", include_in_schema=False)

app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.models.monitor import Monitor
1313
from app.models.smartphone import Smartphone
1414
from app.models.soc import SoC
15+
from app.models.software import Software
1516

1617
__all__ = [
1718
"Brand",
@@ -25,4 +26,5 @@
2526
"Laptop",
2627
"Monitor",
2728
"Game",
29+
"Software",
2830
]

app/models/software.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Software model (§6.11).
2+
3+
A software application/program. Like games, software references no Brand — its
4+
makers are free-text ``developers`` / ``publishers`` lists. Unscored.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from datetime import UTC, date, datetime
10+
11+
from sqlalchemy import JSON, Column
12+
from sqlmodel import Field, SQLModel
13+
14+
15+
def _utcnow() -> datetime:
16+
return datetime.now(UTC)
17+
18+
19+
class Software(SQLModel, table=True):
20+
"""A software product (e.g. Blender, Firefox)."""
21+
22+
__tablename__ = "software"
23+
24+
id: int | None = Field(default=None, primary_key=True)
25+
slug: str = Field(index=True, unique=True)
26+
name: str
27+
28+
release_date: date | None = None
29+
30+
developers: list[str] = Field(default_factory=list, sa_column=Column(JSON))
31+
publishers: list[str] = Field(default_factory=list, sa_column=Column(JSON))
32+
operating_systems: list[str] = Field(default_factory=list, sa_column=Column(JSON))
33+
programming_languages: list[str] = Field(default_factory=list, sa_column=Column(JSON))
34+
licenses: list[str] = Field(default_factory=list, sa_column=Column(JSON))
35+
genres: list[str] = Field(default_factory=list, sa_column=Column(JSON))
36+
37+
# Meta
38+
verified: bool = False
39+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
40+
created_at: datetime = Field(default_factory=_utcnow)
41+
updated_at: datetime = Field(default_factory=_utcnow)

app/routers/software.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Software endpoints (§6.11). List + detail; software is unscored."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated, Any
6+
7+
from fastapi import APIRouter, Query
8+
from sqlalchemy import func
9+
from sqlmodel import select
10+
from sqlmodel.sql.expression import SelectOfScalar
11+
12+
from app.dependencies import PaginationDep, SessionDep
13+
from app.errors import APIError, not_found
14+
from app.models.software import Software
15+
from app.routers.utils import build_ref_page
16+
from app.schemas.common import Page, ResourceRef
17+
from app.schemas.serializers import resource_ref, software_read
18+
from app.schemas.software import SoftwareRead
19+
20+
router = APIRouter(prefix="/software", tags=["software"])
21+
22+
_SORT_FIELDS: dict[str, Any] = {
23+
"name": Software.name,
24+
"release_date": Software.release_date,
25+
}
26+
27+
28+
def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
29+
if not sort:
30+
return stmt.order_by(Software.name)
31+
descending = sort.startswith("-")
32+
field = sort[1:] if descending else sort
33+
column = _SORT_FIELDS.get(field)
34+
if column is None:
35+
raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'")
36+
return stmt.order_by(column.desc() if descending else column.asc())
37+
38+
39+
@router.get("", summary="List software")
40+
def list_software(
41+
session: SessionDep,
42+
pagination: PaginationDep,
43+
sort: Annotated[str | None, Query()] = None,
44+
) -> Page[ResourceRef]:
45+
count = session.exec(select(func.count()).select_from(Software)).one()
46+
list_stmt = _apply_sort(select(Software), sort)
47+
list_stmt = list_stmt.offset(pagination.offset).limit(pagination.limit)
48+
rows = session.exec(list_stmt).all()
49+
50+
refs = [resource_ref("software", row.slug, row.name) for row in rows]
51+
applied = {k: v for k, v in (("sort", sort),) if v}
52+
return build_ref_page(
53+
refs, count=count, path="/v1/software", pagination=pagination, filters=applied
54+
)
55+
56+
57+
@router.get("/{slug}", summary="Get a software product")
58+
def get_software(slug: str, session: SessionDep) -> SoftwareRead:
59+
software = session.exec(select(Software).where(Software.slug == slug)).first()
60+
if software is None:
61+
raise not_found("Software", slug)
62+
return software_read(software)

app/schemas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from app.schemas.monitor import MonitorRead
88
from app.schemas.smartphone import ScoreRead, SmartphoneRead
99
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary
10+
from app.schemas.software import SoftwareRead
1011

1112
__all__ = [
1213
"Page",
@@ -23,4 +24,5 @@
2324
"LaptopRead",
2425
"MonitorRead",
2526
"GameRead",
27+
"SoftwareRead",
2628
]

app/schemas/serializers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.models.monitor import Monitor
1313
from app.models.smartphone import Smartphone
1414
from app.models.soc import SoC
15+
from app.models.software import Software
1516
from app.schemas.brand import BrandRead, BrandSummary
1617
from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef
1718
from app.schemas.cpu import CPURead, CPUScoreRead
@@ -22,6 +23,7 @@
2223
from app.schemas.monitor import MonitorRead
2324
from app.schemas.smartphone import ScoreRead, SmartphoneRead
2425
from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary
26+
from app.schemas.software import SoftwareRead
2527
from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore
2628

2729
PREFIX = settings.api_version_prefix
@@ -405,3 +407,24 @@ def game_read(game: Game) -> GameRead:
405407
updated_at=game.updated_at,
406408
url=url_for("games", game.slug),
407409
)
410+
411+
412+
def software_read(software: Software) -> SoftwareRead:
413+
assert software.id is not None
414+
return SoftwareRead(
415+
id=software.id,
416+
slug=software.slug,
417+
name=software.name,
418+
release_date=software.release_date,
419+
developers=software.developers,
420+
publishers=software.publishers,
421+
operating_systems=software.operating_systems,
422+
programming_languages=software.programming_languages,
423+
licenses=software.licenses,
424+
genres=software.genres,
425+
verified=software.verified,
426+
source_urls=software.source_urls,
427+
created_at=software.created_at,
428+
updated_at=software.updated_at,
429+
url=url_for("software", software.slug),
430+
)

app/schemas/software.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Software response schema (§6.11). Software is unscored (no ``score`` field)."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date, datetime
6+
7+
from pydantic import BaseModel
8+
9+
10+
class SoftwareRead(BaseModel):
11+
"""Full software detail response."""
12+
13+
id: int
14+
slug: str
15+
name: str
16+
release_date: date | None = None
17+
developers: list[str]
18+
publishers: list[str]
19+
operating_systems: list[str]
20+
programming_languages: list[str]
21+
licenses: list[str]
22+
genres: list[str]
23+
verified: bool
24+
source_urls: list[str]
25+
created_at: datetime
26+
updated_at: datetime
27+
url: str

app/seed.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from app.models.monitor import Monitor
3434
from app.models.smartphone import Smartphone
3535
from app.models.soc import SoC
36+
from app.models.software import Software
3637

3738
DATA_DIR = get_data_root()
3839

@@ -69,6 +70,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]:
6970
"laptops": 0,
7071
"monitors": 0,
7172
"games": 0,
73+
"software": 0,
7274
}
7375

7476
# --- Brands ---
@@ -233,6 +235,15 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N
233235
counts["games"] += 1
234236
session.commit()
235237

238+
# --- Software (standalone; no brand FK) ---
239+
software_slugs = _existing_slugs(session, Software)
240+
for record in _load_dir(data_dir / "software"):
241+
if record["slug"] in software_slugs:
242+
continue
243+
session.add(Software(**record))
244+
counts["software"] += 1
245+
session.commit()
246+
236247
return counts
237248

238249

0 commit comments

Comments
 (0)