Author SHA1 Message Date
patrick ead43b2562 fix: download every selected book, not just the last
ci / backend (push) Failing after 3m3s
ci / frontend (push) Successful in 2m2s
The frontend built book_ids with `set` rather than `append`, so the archive
only ever held one book. The proxy also sent an empty Content-Length, which
is not valid HTTP/1.1, and buffered the whole zip before forwarding a byte
of it.

Entries now carry the file's own name, suffixed when two books claim the
same one, and the books are resolved before the stream starts so a miss is
a 404 instead of a truncated archive under a 200.
2026-09-14 11:04:47 -04:00
patrick 412a73cedf fix: tell the tests where the database actually is
ci / backend (push) Failing after 1m16s
ci / frontend (push) Successful in 1m58s
DOCKER_HOST only decides which daemon creates the container; the address the
test dials comes from POSTGRES_HOST, which defaults to the job container's own
loopback. Setting one without the other still times out.
2026-08-18 00:49:41 -04:00
patrick 5176dfcb77 fix: give the backend CI job a docker daemon it can address
ci / backend (push) Failing after 1m30s
ci / frontend (push) Successful in 1m58s
The runner has docker, so the database container started; the job just could
not reach it. pytest-databases takes the address from DOCKER_HOST, and unset
means 127.0.0.1 -- the job container's loopback, not the namespace the sibling
published on. A dind service makes it resolve to a host that answers.
2026-08-18 00:45:43 -04:00
patrick b85ba92380 fix: declare ruff as a dev dependency
ci / backend (push) Failing after 1m7s
ci / frontend (push) Successful in 1m59s
CI has no nix shell, and ruff was only ever a nix package, so both lint steps
died with "Failed to spawn: ruff". Pinned exactly: the formatter deciding what
is correct must be one version, or CI rejects what the dev shell produced.
2026-08-18 00:37:00 -04:00
11 changed files with 316 additions and 68 deletions
+24 -2
View File
@@ -19,6 +19,30 @@ jobs:
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# pytest-databases starts PostgreSQL in a container and then connects to it, and those are
# two different addresses that have to be set separately:
#
# DOCKER_HOST which daemon to create the container on (_service.py get_docker_host)
# POSTGRES_HOST where the test then connects (docker/postgres.py, default
# 127.0.0.1 -- the job container's own loopback, where nothing listens,
# because the database is a sibling container on another namespace)
#
# Setting only the first leaves the tests dialling 127.0.0.1 and timing out with
# "Service 'pytest_databases_postgres' failed to come online".
#
# If the runner is ever given its own dind sidecar, drop this services block and keep the
# two env vars pointed at whatever host it exposes.
services:
docker:
image: docker:27-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ''
env:
DOCKER_HOST: tcp://docker:2375
POSTGRES_HOST: docker
defaults: defaults:
run: run:
working-directory: backend working-directory: backend
@@ -42,8 +66,6 @@ jobs:
- name: Lint - name: Lint
run: uv run ruff check src/ tests/ run: uv run ruff check src/ tests/
# pytest-databases starts a throwaway PostgreSQL container, so this needs a working
# Docker daemon on the runner — the same requirement the release workflow has.
- name: Tests - name: Tests
run: uv run pytest tests/ -q run: uv run pytest tests/ -q
+1
View File
@@ -36,6 +36,7 @@ dev = [
"pytest>=8.4.2", "pytest>=8.4.2",
"pytest-asyncio>=1.2.0", "pytest-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0", "pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Python development environment for Chitai # Python development environment for Chitai
python313Packages.greenlet python313Packages.greenlet
python313Packages.ruff # ruff is a dev dependency in pyproject.toml, not a shell package: CI has no nix, so a
# nix-only formatter is one CI cannot run, and two copies could disagree on formatting.
uv uv
# postgres database # postgres database
+7 -1
View File
@@ -509,9 +509,15 @@ class BookController(Controller):
Returns: Returns:
A stream of the compressed ZIP file. A stream of the compressed ZIP file.
Raises:
FileNotFoundError: If the selection turned up no file that is on disk,
which the handler renders as a 404. Awaiting here is what makes that
possible: the books are resolved before the response begins, so a
miss is still a status code rather than a truncated archive.
""" """
return Stream( return Stream(
books_service.get_files(book_ids, library_id), await books_service.get_files(book_ids, library_id),
headers={ headers={
"Content-Disposition": 'attachment; filename="download.zip"', "Content-Disposition": 'attachment; filename="download.zip"',
"Content-Type": "application/zip", "Content-Type": "application/zip",
+21 -1
View File
@@ -12,4 +12,24 @@ def not_found_exception_handler(_: Request, exc: NotFoundError) -> Response:
) )
exception_handlers = {NotFoundError: not_found_exception_handler} def file_not_found_exception_handler(_: Request, exc: FileNotFoundError) -> Response:
"""
Handler for a row that describes a file the caller cannot be given.
The row exists, so `NotFoundError` is the wrong answer and the services raise
`FileNotFoundError` instead: the book is real, the file it names is not on disk,
or the file belongs to another book. All three are the caller asking for
something that is not there, which is a 404 — before this they escaped as
`ValueError` and reached the client as a 500.
"""
return Response(
media_type=MediaType.TEXT,
content=f"Not found error: {exc}",
status_code=404,
)
exception_handlers = {
NotFoundError: not_found_exception_handler,
FileNotFoundError: file_not_found_exception_handler,
}
+95 -31
View File
@@ -243,6 +243,67 @@ def _unused_path(path: Path) -> Path:
return candidate return candidate
def _unused_name(name: str, taken: set[str]) -> str:
"""
The given name, or the first free `name (n).ext` beside it.
The in-memory counterpart of `_unused_path`, for names within an archive rather
than a directory. Two books can hold a file called `Dune.epub` — nothing stops
them, since each lives in its own directory — and a zip happily stores the name
twice, which loses one of them on extraction.
Args:
name: The filename to place.
taken: Names already placed; the chosen one is added to it.
Returns:
A name that nothing in `taken` occupies.
"""
path = Path(name)
candidate = name
suffix = 1
while candidate in taken:
suffix += 1
candidate = f"{path.stem} ({suffix}){path.suffix}"
taken.add(candidate)
return candidate
async def _stream_zip(entries: list[tuple[str, Path]]) -> AsyncIterator[bytes]:
"""
Compress already-resolved files into a zip, a chunk at a time.
Args:
entries: `(name within the archive, path on disk)` pairs, in archive order.
Yields:
Chunks of compressed zip file data.
"""
stream = _ZipStream()
chunk_size = 32768 # 32 KiB
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
for arcname, path in entries:
info = zipfile.ZipInfo.from_file(path, arcname=arcname)
info.compress_type = zipfile.ZIP_DEFLATED
with zip_file.open(info, "w") as entry:
async with aiofiles.open(path, "rb") as source:
while content := await source.read(chunk_size):
entry.write(content)
if chunk := stream.drain():
yield chunk
if chunk := stream.drain():
yield chunk
# The central directory, written when ZipFile closes.
if chunk := stream.drain():
yield chunk
def _title_keys(title: str, authors: set[str]) -> set[str]: def _title_keys(title: str, authors: set[str]) -> set[str]:
""" """
Every normalized title one book could reasonably be filed under. Every normalized title one book could reasonably be filed under.
@@ -1815,11 +1876,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
A File object ready for download. A File object ready for download.
Raises: Raises:
ValueError: If the file is missing or not found for the given book. FileNotFoundError: If the file is missing or not found for the given book.
""" """
book = await self.get(book_id) book = await self.get(book_id)
if book.path is None: if book.path is None:
raise ValueError("Cannot download file: book has no path") raise FileNotFoundError("Cannot download file: book has no path")
for file in book.files: for file in book.files:
if file.id != file_id: if file.id != file_id:
@@ -1827,16 +1888,23 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
path = Path(book.path) / Path(file.path) path = Path(book.path) / Path(file.path)
if not await aios.path.isfile(path): if not await aios.path.isfile(path):
raise ValueError("The file is missing") raise FileNotFoundError("The file is missing")
# Rows written before `guess_content_type` existed can hold null here, and # Rows written before `guess_content_type` existed can hold null here, and
# so can `guess_content_type` itself. Litestar fills a null in from the # so can `guess_content_type` itself. Litestar fills a null in from the
# filename, falling back to `application/octet-stream` on the response. # filename, falling back to `application/octet-stream` on the response.
#
# `filename` is what puts a name in the Content-Disposition. Without it
# Litestar sends `attachment; filename=""` and a client that has nothing
# else to go on — an OPDS reader, curl -O — names the file after the
# numeric URL, extension and all.
return File( return File(
path, media_type=file.content_type or guess_content_type(file.path) path,
filename=Path(file.path).name,
media_type=file.content_type or guess_content_type(file.path),
) )
raise ValueError("No such file for the given book") raise FileNotFoundError("No such file for the given book")
async def get_files( async def get_files(
self, book_ids: list[int], library_id: int self, book_ids: list[int], library_id: int
@@ -1844,24 +1912,33 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
""" """
Get all selected book files as a compressed zip file. Get all selected book files as a compressed zip file.
Streams the zip file in chunks to avoid loading the entire archive into memory. Every book and path is resolved here, up front, and only the compression is
Each book gets its own directory in the archive, so two books that happen to left to the returned iterator. That ordering is the point: a generator that
share a filename do not overwrite one another. Files that have a row but no queried on its first iteration would run *after* the response had already
longer exist on disk are skipped. started, since Litestar only drains a `Stream` once the headers are away — so
a missing book or a database error arrived as a truncated archive under a
`200` rather than as an error anyone could act on.
Entries carry the file's own name. A name claimed twice — two books can each
hold a `Dune.epub`, living as they do in separate directories — gets the
second suffixed to `Dune (2).epub`, so nothing is overwritten on extraction.
Files that have a row but no longer exist on disk are skipped.
Args: Args:
book_ids: List of book IDs to include in the zip. book_ids: List of book IDs to include in the zip.
library_id: The ID of the library containing the books. library_id: The ID of the library containing the books.
Yields: Returns:
Chunks of compressed zip file data. An async iterator over chunks of compressed zip file data.
Raises:
FileNotFoundError: If the selection turned up no file that is on disk.
""" """
books = await self.list(Book.id.in_(book_ids), Book.library_id == library_id) books = await self.list(Book.id.in_(book_ids), Book.library_id == library_id)
stream = _ZipStream() entries: list[tuple[str, Path]] = []
chunk_size = 32768 # 32 KiB taken: set[str] = set()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
for book in books: for book in books:
# Books without files have no path, and `file.path` is stored relative # Books without files have no path, and `file.path` is stored relative
# to it, so there is nothing to resolve against. # to it, so there is nothing to resolve against.
@@ -1874,25 +1951,12 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
if not await aios.path.isfile(path): if not await aios.path.isfile(path):
continue continue
# Namespaced by the book's own directory to avoid collisions. entries.append((_unused_name(Path(file.path).name, taken), path))
info = zipfile.ZipInfo.from_file(
path, arcname=f"{book_path.name}/{Path(file.path).name}"
)
info.compress_type = zipfile.ZIP_DEFLATED
with zip_file.open(info, "w") as entry: if not entries:
async with aiofiles.open(path, "rb") as source: raise FileNotFoundError("No files to download for the given books")
while content := await source.read(chunk_size):
entry.write(content)
if chunk := stream.drain():
yield chunk
if chunk := stream.drain(): return _stream_zip(entries)
yield chunk
# The central directory, written when ZipFile closes.
if chunk := stream.drain():
yield chunk
async def update_book( async def update_book(
self, book_id: int, update_data: ModelDictT[Book], library: Library self, book_id: int, update_data: ModelDictT[Book], library: Library
@@ -221,7 +221,9 @@ class TestBookServiceCRUD:
archive_bytes = b"".join( archive_bytes = b"".join(
[ [
chunk chunk
async for chunk in books_service.get_files([book.id], test_library.id) async for chunk in await books_service.get_files(
[book.id], test_library.id
)
] ]
) )
@@ -231,13 +233,79 @@ class TestBookServiceCRUD:
names = archive.namelist() names = archive.namelist()
assert len(names) == len(contents) assert len(names) == len(contents)
# Entries are namespaced by the book's directory, so two books sharing a # Entries carry the file's own name, with no directory in front of it.
# filename cannot overwrite one another. assert set(names) == set(contents)
assert {Path(name).parent.name for name in names} == {"Dracula"}
for name, payload in contents.items(): for name, payload in contents.items():
entry = next(n for n in names if Path(n).name == name) assert archive.read(name) == payload
assert archive.read(entry) == payload
async def test_get_files_suffixes_a_name_claimed_twice(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Two books can each hold a `Dune.epub`; the archive must keep both.
Entries are named after the file, and a zip stores the same name twice
happily — leaving whichever copy was written last as the one that survives
extraction. The second is suffixed instead.
"""
payloads = {
"Herbert": b"herbert-payload-" * 512,
"Other": b"other-payload-" * 512,
}
book_ids = []
for author, payload in payloads.items():
book_data = BookCreate(
library_id=test_library.id,
title="Dune",
authors=[author],
pages=412,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
book.path = f"{test_library.root_path}/{author}/Dune"
await aios.makedirs(book.path) # type: ignore[arg-type]
Path(book.path, "Dune.epub").write_bytes(payload)
book.files.append(
m.FileMetadata(
path="Dune.epub",
size=len(payload),
hash=f"hash-{author}",
content_type=None,
)
)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
book_ids.append(book.id)
archive_bytes = b"".join(
[
chunk
async for chunk in await books_service.get_files(
book_ids, test_library.id
)
]
)
with zipfile.ZipFile(BytesIO(archive_bytes)) as archive:
assert archive.testzip() is None
assert sorted(archive.namelist()) == ["Dune (2).epub", "Dune.epub"]
stored = {archive.read(name) for name in archive.namelist()}
assert stored == set(payloads.values())
async def test_get_files_refuses_a_selection_with_nothing_in_it(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A miss is a 404, raised before the response starts.
Resolving lazily inside the stream meant an empty selection produced a valid
22-byte archive under a 200, which tells the caller nothing went wrong.
"""
with pytest.raises(FileNotFoundError):
await books_service.get_files([424242], test_library.id)
@pytest.mark.asyncio @pytest.mark.asyncio
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] }, { name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" }, { name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
] ]
[[package]] [[package]]
@@ -1277,6 +1279,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
+31 -6
View File
@@ -99,12 +99,37 @@ the workflow.
1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a 1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a
runner. Register one against the repo or the instance with the label `ubuntu-latest`. runner. Register one against the repo or the instance with the label `ubuntu-latest`.
2. **The runner needs a Docker daemon.** This is the single most common failure for image-building 2. **The runner needs a Docker daemon, at an address the job can reach.** `act_runner` in docker
workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no mode runs each job inside a container with no daemon of its own. Either run a `docker:dind`
daemon of its own. Either run a `docker:dind` sidecar next to the runner and set sidecar next to the runner and set `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a
`DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the
mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket host socket into job containers gives any workflow root on the runner host.
into job containers gives any workflow root on the runner host.
**Reachability is a separate question from availability, and it bit us.** A containerised job
with a working daemon still fails `pytest tests/` with
`Service 'pytest_databases_postgres' failed to come online`: the database container starts
fine, but its published port lands on the daemon's network namespace while the test process
looks for it on the job container's loopback.
Two variables control two different things, and both must be set:
| Variable | Decides | Read by |
| --- | --- | --- |
| `DOCKER_HOST` | which daemon the container is created on | `_service.py` `get_docker_host()` |
| `POSTGRES_HOST` | the address the test then connects to | `docker/postgres.py` `postgres_host`, default `127.0.0.1` |
Setting only `DOCKER_HOST` is not enough — `DockerService.run()` takes `container_host` as a
plain argument defaulting to `127.0.0.1`, and the postgres fixture fills it from
`POSTGRES_HOST`. (There *is* a `DOCKER_HOST`-parsing helper in `pytest_databases`, but it is
`_get_docker_ip()` on the docker-compose class in `docker/__init__.py` and no part of this
path uses it. Do not be misled by it, as I was.)
Until the runner grows its own sidecar, `ci.yml`'s backend job carries a `docker:dind` service
of its own with `DOCKER_HOST: tcp://docker:2375`. That needs the runner to permit
`--privileged`. The same treatment is still owed to `release.yml` — its `quality` job runs the
same tests, and its `smoke` job talks to compose, where the published ports would move to the
dind host too, so `curl http://localhost:3000` becomes `curl http://docker:3000`. Configuring
the runner once (option 1) avoids all of that.
3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here. 3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here.
Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`. Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`.
That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`, That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`,
@@ -134,8 +134,11 @@ export class BookOperationsState {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const downloadUrl = new URL(`/api/books/download`, page.url.origin); const downloadUrl = new URL(`/api/books/download`, page.url.origin);
downloadUrl.searchParams.set('library_id', this.libraryId); downloadUrl.searchParams.set('library_id', this.libraryId);
// `append`, not `set` — `set` replaces the existing value, so every book but
// the last was dropped from the query string and the archive came back holding
// one book however many were selected.
bookIds.forEach((id) => { bookIds.forEach((id) => {
downloadUrl.searchParams.set('book_ids', id.toString()); downloadUrl.searchParams.append('book_ids', id.toString());
}); });
this.download(downloadUrl, filename || 'download.zip'); this.download(downloadUrl, filename || 'download.zip');
+20 -9
View File
@@ -16,16 +16,27 @@ async function handleResponse(response: Response) {
const data = await response.json(); const data = await response.json();
return json(data); return json(data);
} else { } else {
// Handle file or other non-JSON response // Only headers the backend actually sent are forwarded. Defaulting a missing
const responseBody = await response.arrayBuffer(); // one to '' put `Content-Length: ` on the wire — SvelteKit's `setResponse`
// hands every header to `res.setHeader` as-is, empty or not — and an empty
return new Response(responseBody, { // Content-Length is not valid HTTP/1.1, so strict clients reject the whole
status: response.status, // response. The multi-book ZIP is streamed and carries no Content-Length,
headers: { // which made it the one download that always tripped over this.
'Content-Type': contentType, const headers = new Headers({ 'Content-Type': contentType });
'Content-Disposition': response.headers.get('Content-Disposition') || '', for (const name of ['Content-Disposition', 'Content-Length']) {
'Content-Length': response.headers.get('Content-Length') || '' const value = response.headers.get(name);
if (value) headers.set(name, value);
} }
// The body is passed through as a stream, matching what `bodyOf` does for
// requests. `arrayBuffer()` held the whole response here first, so nothing
// reached the browser until the last byte had been read and this process
// carried a copy of it — survivable for one book, not for a ZIP of a whole
// shelf, which only starts arriving once the backend has finished
// compressing all of it.
return new Response(response.body, {
status: response.status,
headers
}); });
} }
} }