Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ead43b2562 | ||
|
|
412a73cedf | ||
|
|
5176dfcb77 | ||
|
|
b85ba92380 |
+24
-2
@@ -19,6 +19,30 @@ jobs:
|
||||
backend:
|
||||
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:
|
||||
run:
|
||||
working-directory: backend
|
||||
@@ -42,8 +66,6 @@ jobs:
|
||||
- name: Lint
|
||||
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
|
||||
run: uv run pytest tests/ -q
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ dev = [
|
||||
"pytest>=8.4.2",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"pytest-databases[postgres]>=0.15.0",
|
||||
"ruff==0.15.14",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
@@ -48,4 +49,4 @@ asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"ignore::jwt.warnings.InsecureKeyLengthWarning",
|
||||
]
|
||||
]
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Python development environment for Chitai
|
||||
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
|
||||
|
||||
# postgres database
|
||||
|
||||
@@ -509,9 +509,15 @@ class BookController(Controller):
|
||||
|
||||
Returns:
|
||||
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(
|
||||
books_service.get_files(book_ids, library_id),
|
||||
await books_service.get_files(book_ids, library_id),
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="download.zip"',
|
||||
"Content-Type": "application/zip",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -243,6 +243,67 @@ def _unused_path(path: Path) -> Path:
|
||||
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]:
|
||||
"""
|
||||
Every normalized title one book could reasonably be filed under.
|
||||
@@ -1815,11 +1876,11 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
||||
A File object ready for download.
|
||||
|
||||
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)
|
||||
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:
|
||||
if file.id != file_id:
|
||||
@@ -1827,16 +1888,23 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
||||
|
||||
path = Path(book.path) / Path(file.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
|
||||
# so can `guess_content_type` itself. Litestar fills a null in from the
|
||||
# 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(
|
||||
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(
|
||||
self, book_ids: list[int], library_id: int
|
||||
@@ -1844,55 +1912,51 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
||||
"""
|
||||
Get all selected book files as a compressed zip file.
|
||||
|
||||
Streams the zip file in chunks to avoid loading the entire archive into memory.
|
||||
Each book gets its own directory in the archive, so two books that happen to
|
||||
share a filename do not overwrite one another. Files that have a row but no
|
||||
longer exist on disk are skipped.
|
||||
Every book and path is resolved here, up front, and only the compression is
|
||||
left to the returned iterator. That ordering is the point: a generator that
|
||||
queried on its first iteration would run *after* the response had already
|
||||
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:
|
||||
book_ids: List of book IDs to include in the zip.
|
||||
library_id: The ID of the library containing the books.
|
||||
|
||||
Yields:
|
||||
Chunks of compressed zip file data.
|
||||
Returns:
|
||||
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)
|
||||
|
||||
stream = _ZipStream()
|
||||
chunk_size = 32768 # 32 KiB
|
||||
entries: list[tuple[str, Path]] = []
|
||||
taken: set[str] = set()
|
||||
|
||||
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for book in books:
|
||||
# Books without files have no path, and `file.path` is stored relative
|
||||
# to it, so there is nothing to resolve against.
|
||||
if book.path is None:
|
||||
for book in books:
|
||||
# Books without files have no path, and `file.path` is stored relative
|
||||
# to it, so there is nothing to resolve against.
|
||||
if book.path is None:
|
||||
continue
|
||||
|
||||
book_path = Path(book.path)
|
||||
for file in book.files:
|
||||
path = book_path / file.path
|
||||
if not await aios.path.isfile(path):
|
||||
continue
|
||||
|
||||
book_path = Path(book.path)
|
||||
for file in book.files:
|
||||
path = book_path / file.path
|
||||
if not await aios.path.isfile(path):
|
||||
continue
|
||||
entries.append((_unused_name(Path(file.path).name, taken), path))
|
||||
|
||||
# Namespaced by the book's own directory to avoid collisions.
|
||||
info = zipfile.ZipInfo.from_file(
|
||||
path, arcname=f"{book_path.name}/{Path(file.path).name}"
|
||||
)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
if not entries:
|
||||
raise FileNotFoundError("No files to download for the given books")
|
||||
|
||||
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
|
||||
return _stream_zip(entries)
|
||||
|
||||
async def update_book(
|
||||
self, book_id: int, update_data: ModelDictT[Book], library: Library
|
||||
|
||||
@@ -221,7 +221,9 @@ class TestBookServiceCRUD:
|
||||
archive_bytes = b"".join(
|
||||
[
|
||||
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()
|
||||
assert len(names) == len(contents)
|
||||
|
||||
# Entries are namespaced by the book's directory, so two books sharing a
|
||||
# filename cannot overwrite one another.
|
||||
assert {Path(name).parent.name for name in names} == {"Dracula"}
|
||||
# Entries carry the file's own name, with no directory in front of it.
|
||||
assert set(names) == set(contents)
|
||||
|
||||
for name, payload in contents.items():
|
||||
entry = next(n for n in names if Path(n).name == name)
|
||||
assert archive.read(entry) == payload
|
||||
assert archive.read(name) == 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
|
||||
|
||||
Generated
+27
@@ -261,6 +261,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-databases", extra = ["postgres"] },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -286,6 +287,7 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
|
||||
{ name = "ruff", specifier = "==0.15.14" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
|
||||
@@ -99,12 +99,37 @@ the workflow.
|
||||
|
||||
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`.
|
||||
2. **The runner needs a Docker daemon.** This is the single most common failure for image-building
|
||||
workflows on Gitea. `act_runner` in docker mode runs each job inside a container that has no
|
||||
daemon of its own. Either run a `docker:dind` sidecar next to the runner and set
|
||||
`DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a volume), or run the runner in host
|
||||
mode with the socket mounted. The dind sidecar is the safer of the two — mounting the host socket
|
||||
into job containers gives any workflow root on the runner host.
|
||||
2. **The runner needs a Docker daemon, at an address the job can reach.** `act_runner` in docker
|
||||
mode runs each job inside a container with no daemon of its own. Either run a `docker:dind`
|
||||
sidecar next to the runner and set `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a
|
||||
volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the
|
||||
host socket 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.
|
||||
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`,
|
||||
|
||||
@@ -134,8 +134,11 @@ export class BookOperationsState {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
|
||||
const downloadUrl = new URL(`/api/books/download`, page.url.origin);
|
||||
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) => {
|
||||
downloadUrl.searchParams.set('book_ids', id.toString());
|
||||
downloadUrl.searchParams.append('book_ids', id.toString());
|
||||
});
|
||||
|
||||
this.download(downloadUrl, filename || 'download.zip');
|
||||
|
||||
@@ -16,16 +16,27 @@ async function handleResponse(response: Response) {
|
||||
const data = await response.json();
|
||||
return json(data);
|
||||
} else {
|
||||
// Handle file or other non-JSON response
|
||||
const responseBody = await response.arrayBuffer();
|
||||
// Only headers the backend actually sent are forwarded. Defaulting a missing
|
||||
// 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
|
||||
// Content-Length is not valid HTTP/1.1, so strict clients reject the whole
|
||||
// response. The multi-book ZIP is streamed and carries no Content-Length,
|
||||
// which made it the one download that always tripped over this.
|
||||
const headers = new Headers({ 'Content-Type': contentType });
|
||||
for (const name of ['Content-Disposition', 'Content-Length']) {
|
||||
const value = response.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
|
||||
return new Response(responseBody, {
|
||||
// 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: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('Content-Disposition') || '',
|
||||
'Content-Length': response.headers.get('Content-Length') || ''
|
||||
}
|
||||
headers
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user