generated from john/python-template
gpt-5.3 codex review: Phase 7 and the addition of the new test-effectiveness-auditor skill.
Quality Gate / gate (push) Failing after 12s
Quality Gate / gate (push) Failing after 12s
This commit is contained in:
@@ -4,12 +4,13 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.api.health import router
|
||||
from transcription.worker import WorkerHealthSnapshot
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Verify /healthz endpoint behavior."""
|
||||
|
||||
def test_healthz_returns_ok_status(self):
|
||||
def test_healthz_returns_ok_status_with_unknown_worker_when_uninitialized(self):
|
||||
"""GET /healthz returns a healthy status payload."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
@@ -18,4 +19,26 @@ class TestHealthEndpoint:
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
assert response.json() == {"status": "ok", "worker": {"state": "unknown"}}
|
||||
|
||||
def test_healthz_returns_worker_failure_metadata_when_available(self):
|
||||
app = FastAPI()
|
||||
app.state.worker_health = WorkerHealthSnapshot(
|
||||
state="failed",
|
||||
error_id="abc12345",
|
||||
error_category="internal_unexpected_error",
|
||||
)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ok",
|
||||
"worker": {
|
||||
"state": "failed",
|
||||
"error_id": "abc12345",
|
||||
"error_category": "internal_unexpected_error",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
source: Book Two - page 02.jpg
|
||||
provider: openrouter
|
||||
model: google/gemini-2.5-flash
|
||||
model: openai/gpt-5.3-codex
|
||||
---
|
||||
[document body typewritten]
|
||||
BY WAY OF INTRODUCTION:-
|
||||
|
||||
These few paragraphs of introduction may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days).
|
||||
These few paragraphs of introduotion [sic] may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days).
|
||||
|
||||
BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 consisted largely of first generation family history. BOOK 2 throws more light on the second generation. Sidney promises a BOOK 3 and that may begin to do justice to the third generation. We suggest that Sidney get the help of Louis Shinn who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq Plains. Louis has the gift of seeing, recalling and telling. One sentence in his chapter gives a great tribute to the Doumecqers--so far as he knows no one on the Doumecq Plains went on relief during the depression. That in a nutshell shows the sturdy character of the residents of the Doumecq Plains.
|
||||
|
||||
@@ -13,5 +13,6 @@ We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of John
|
||||
|
||||
Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection.
|
||||
|
||||
We are much impressed with the future possibilities of getting a complete geneol-ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
|
||||
-2-
|
||||
We are much impressed with the future possibilities of getting a complete genealogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
|
||||
|
||||
~2~
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
source: Omie Writes Home.pdf
|
||||
provider: openrouter
|
||||
model: google/gemini-2.5-flash
|
||||
model: openai/gpt-5.3-codex
|
||||
---
|
||||
[document body mixed]
|
||||
JOHN E. COCHRAN
|
||||
FAMILY ASSOCIATION
|
||||
[document body typeset]
|
||||
Family Only
|
||||
[handwritten: Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF]
|
||||
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
|
||||
|
||||
OMIE WRITES HOME
|
||||
|
||||
Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her
|
||||
sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the
|
||||
original envelope with its 2 cent stamp. The letter has a number of references to the Shinn
|
||||
@@ -16,7 +16,9 @@ Miss Saville was the nurse at the Nome Hospital that was mentioned in the articl
|
||||
the family newsletter two years ago.
|
||||
|
||||
Nome Alaska August 26, 1923
|
||||
|
||||
My Dear Ethel et al.
|
||||
|
||||
I don't know when I did write or when you did
|
||||
but I am going to write now however and never
|
||||
the less. But I wish I could talk (I can yet but I
|
||||
@@ -26,8 +28,6 @@ rascal of yours would fairly sparkle with
|
||||
listening. Can't I see him listening now to all the
|
||||
yarns we told last summer?
|
||||
|
||||
[photo: Five people, appearing to be native Alaskan, in winter clothing, standing on a beach with a dog sled in front of them and a boat in the background]
|
||||
|
||||
You see, we-Miss Saville and I, took a trip north
|
||||
on the Buford and it was very interesting. We
|
||||
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
||||
@@ -35,4 +35,84 @@ of our craft and some other mighty hunters went out first in kayaks and later in
|
||||
shot seven walrus. When they also took a movie man and camera, so you will likely see all
|
||||
this in the movies before I get to tell you. They came back on board and the ship went up
|
||||
along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads
|
||||
tons of freight and the beasts were
|
||||
tons of freight and the beasts were so huge that they made the pulleys just creak. They were
|
||||
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
|
||||
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
|
||||
|
||||
[photograph of people standing outdoors in snow]
|
||||
|
||||
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
|
||||
the most repulsive looking animals imaginable and tho I have always read about them I never
|
||||
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
||||
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
||||
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
|
||||
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
|
||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
|
||||
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
|
||||
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
|
||||
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
||||
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
|
||||
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
|
||||
babies and children. They wore skin clothes entirely. The women's were made like bloomers
|
||||
and were heavily padded for warmth. They wore high mukluks and really looked very
|
||||
comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy
|
||||
bears with faces. I guess they had never seen white women, not so many at one time anyway.
|
||||
We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band
|
||||
playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
|
||||
|
||||
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists'
|
||||
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
||||
something to buy. Prices raised right before your eyes. One would but something for $1.00
|
||||
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
|
||||
have it. One man I was sort of taking care of, tho he had his son along for the purpose,
|
||||
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
|
||||
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
|
||||
little natives throw them at the flocks of geese and ducks which fly close over the village and
|
||||
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
|
||||
come down and the natives capture them. There was more junk brot aboard than baggage, I
|
||||
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
||||
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||
number.
|
||||
|
||||
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
||||
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
||||
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
||||
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
|
||||
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
|
||||
|
||||
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
||||
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
||||
|
||||
I have had jobs nearly all summer but not very much in them. Next week, September 4,
|
||||
school opens. I wish they would wait for a week but you know these school men. Wouldn't
|
||||
make any special difference I suppose for I would just fritter away the time but still one likes
|
||||
to postpone the inevitable.
|
||||
|
||||
I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray
|
||||
for reading over your shoulder. It would seem, then that you have bright children. Maybe
|
||||
they do know something about Geography. But it is ridiculous to speak of Louis finishing the
|
||||
eighth grade. Why you and I were grown children when we finished and he is only a baby. I
|
||||
am rather afraid he doesn't know much. I quite remember your little timid Maurice and how
|
||||
he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a
|
||||
week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose
|
||||
he does?
|
||||
|
||||
Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done
|
||||
well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens,
|
||||
I'll write again. Don't suppose it ever will, tho.
|
||||
|
||||
Lots of love to all,
|
||||
|
||||
Ome
|
||||
|
||||
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
|
||||
|
||||
© JECFA 1986
|
||||
|
||||
Up
|
||||
|
||||
jecochranclan.org ~ Contact webmaster
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
source: Rod Moser Letter - p1.jpg
|
||||
provider: openrouter
|
||||
model: google/gemini-2.5-flash
|
||||
model: openai/gpt-5.3-codex
|
||||
---
|
||||
[document body mixed]
|
||||
JOHN ISBILL
|
||||
R. T. MOSER
|
||||
JOHN ISBILL R. T. MOSER
|
||||
|
||||
ISBILL & MOSER
|
||||
DEALERS IN
|
||||
GENERAL MERCHANDISE
|
||||
[handwritten: Vonore, Tenn. Jan'y 27 - 1913]
|
||||
|
||||
[handwritten: Dear Much Aunt Louing
|
||||
Vonore, Tenn., [handwritten: July 27-] 191[handwritten: 2]
|
||||
|
||||
[handwritten: Dear Uncle Aunt & Cousins
|
||||
I was at home a
|
||||
few nights ago & saw a
|
||||
letter from you folks, so
|
||||
letter from your folks, so
|
||||
I decided to write you
|
||||
a few lines myself &
|
||||
I am contemplating a
|
||||
a few lines in regards of
|
||||
I am [contemplating?] a
|
||||
trip out west next summer
|
||||
& would like of [illegible] to go
|
||||
& want [lots?] of [olders?] to go
|
||||
where I am from.
|
||||
Am getting
|
||||
up in years & unmarried
|
||||
up in years & wondering,
|
||||
so you see the object of
|
||||
my trip, is to get a wife
|
||||
of there is any old maids
|
||||
or widows out there, I
|
||||
want you to kiss them
|
||||
at my [illegible] for me at home
|
||||
as soon as I get there]
|
||||
If there is any old maids
|
||||
or widows out there I
|
||||
want you to [hire?] them
|
||||
at [one?] [find?] me at there
|
||||
as soon as I get there.]
|
||||
|
||||
@@ -1,36 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.services.base import ServiceBase
|
||||
|
||||
class TestServiceBase:
|
||||
class TestInitialization:
|
||||
def test_initializes_with_defaults(self):
|
||||
"""Test initialization with default session factory and queue."""
|
||||
|
||||
def test_initializes_with_custom_session_factory(self):
|
||||
"""Test initialization with a provided session factory."""
|
||||
class _TrackingSession:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.flushes = 0
|
||||
self.refreshed: list[object] = []
|
||||
|
||||
def test_initializes_with_custom_queue(self):
|
||||
"""Test initialization with a provided queue."""
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
class TestSessionScope:
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_provided_session(self):
|
||||
"""Test that session scope reuses a provided session."""
|
||||
async def flush(self) -> None:
|
||||
self.flushes += 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_session_when_none_provided(self):
|
||||
"""Test that session scope creates a new session when none is provided."""
|
||||
async def refresh(self, obj: object) -> None:
|
||||
self.refreshed.append(obj)
|
||||
|
||||
class TestContextManagerBehavior:
|
||||
@pytest.mark.asyncio
|
||||
async def test_yields_session(self):
|
||||
"""Test that session scope yields a usable session object."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_operations(self):
|
||||
"""Test multiple operations within a single session scope."""
|
||||
def _settings() -> Settings:
|
||||
return Settings(_env_file=None, openrouter_api_key="test-key")
|
||||
|
||||
class TestEdgeCases:
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_exception_propagation(self):
|
||||
"""Test exception propagation behavior inside session scope."""
|
||||
|
||||
def test_initializes_with_defaults(monkeypatch):
|
||||
settings = _settings()
|
||||
session_factory = object()
|
||||
|
||||
monkeypatch.setattr("transcription.services.base.get_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
"transcription.services.base.resolve_session_factory",
|
||||
lambda **kwargs: session_factory,
|
||||
)
|
||||
|
||||
service = ServiceBase()
|
||||
|
||||
assert service.settings is settings
|
||||
assert service.session_factory is session_factory
|
||||
|
||||
|
||||
def test_initializes_with_custom_session_factory():
|
||||
settings = _settings()
|
||||
session_factory = object()
|
||||
|
||||
service = ServiceBase(settings=settings, session_factory=session_factory)
|
||||
|
||||
assert service.settings is settings
|
||||
assert service.session_factory is session_factory
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_scope_reuses_provided_session(monkeypatch):
|
||||
captured: dict[str, object | None] = {}
|
||||
provided_session = object()
|
||||
yielded = object()
|
||||
service = ServiceBase(settings=_settings(), session_factory=object())
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session_scope(*, session_factory=None, session=None):
|
||||
captured["session_factory"] = session_factory
|
||||
captured["session"] = session
|
||||
yield yielded if session is None else session
|
||||
|
||||
monkeypatch.setattr("transcription.services.base.session_scope", _fake_session_scope)
|
||||
|
||||
async with service._session_scope(session=provided_session) as active:
|
||||
assert active is provided_session
|
||||
|
||||
assert captured == {"session_factory": service.session_factory, "session": provided_session}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_scope_creates_owned_session_when_none_provided(monkeypatch):
|
||||
captured: dict[str, object | None] = {}
|
||||
owned_session = object()
|
||||
service = ServiceBase(settings=_settings(), session_factory=object())
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session_scope(*, session_factory=None, session=None):
|
||||
captured["session_factory"] = session_factory
|
||||
captured["session"] = session
|
||||
yield owned_session
|
||||
|
||||
monkeypatch.setattr("transcription.services.base.session_scope", _fake_session_scope)
|
||||
|
||||
async with service._session_scope() as active:
|
||||
assert active is owned_session
|
||||
|
||||
assert captured == {"session_factory": service.session_factory, "session": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_scope_propagates_exceptions(monkeypatch):
|
||||
service = ServiceBase(settings=_settings(), session_factory=object())
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session_scope(*, session_factory=None, session=None):
|
||||
_ = (session_factory, session)
|
||||
yield object()
|
||||
|
||||
monkeypatch.setattr("transcription.services.base.session_scope", _fake_session_scope)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async with service._session_scope():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_commits_for_service_owned_session():
|
||||
service = ServiceBase(settings=_settings(), session_factory=object())
|
||||
session = _TrackingSession()
|
||||
refreshed = object()
|
||||
|
||||
await service._finalize(session=session, caller_session=None, refresh=(refreshed,))
|
||||
|
||||
assert session.commits == 1
|
||||
assert session.flushes == 0
|
||||
assert session.refreshed == [refreshed]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_flushes_for_caller_owned_session():
|
||||
service = ServiceBase(settings=_settings(), session_factory=object())
|
||||
session = _TrackingSession()
|
||||
refreshed = object()
|
||||
|
||||
await service._finalize(session=session, caller_session=object(), refresh=(refreshed,))
|
||||
|
||||
assert session.commits == 0
|
||||
assert session.flushes == 1
|
||||
assert session.refreshed == [refreshed]
|
||||
|
||||
@@ -87,11 +87,15 @@ async def test_promotion_rejects_unrelated_attempt(default_session_factory):
|
||||
services = _services(default_session_factory, settings)
|
||||
source = await _seed_source(services)
|
||||
|
||||
with pytest.raises(CandidatePromotionError):
|
||||
with pytest.raises(CandidatePromotionError) as exc_info:
|
||||
await services.evidence.promote_machine_attempt(
|
||||
source_id=source.id,
|
||||
execution_attempt_id=uuid4(),
|
||||
)
|
||||
error = exc_info.value
|
||||
assert error.category.value == "validation_error"
|
||||
assert "successful transcription attempt" in error.message
|
||||
assert "Select an available successful candidate" in error.suggestion
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ class TestAppLifespan:
|
||||
@asynccontextmanager
|
||||
async def _worker_lifespan(**_kwargs):
|
||||
calls.append("worker_start")
|
||||
yield object(), object()
|
||||
yield object(), object(), object()
|
||||
calls.append("worker_stop")
|
||||
|
||||
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||
@@ -110,7 +110,7 @@ class TestAppLifespan:
|
||||
@asynccontextmanager
|
||||
async def _worker_lifespan(**_kwargs):
|
||||
calls.append("worker_start")
|
||||
yield object(), object()
|
||||
yield object(), object(), object()
|
||||
calls.append("worker_stop")
|
||||
|
||||
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for transcription.config — settings loading, provider defaults, and paths."""
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -8,6 +10,7 @@ from pydantic import ValidationError
|
||||
|
||||
from transcription.config import Provider
|
||||
from transcription.config import Settings
|
||||
from transcription.config import configure_logging
|
||||
from transcription.config import parse_cli_settings
|
||||
|
||||
|
||||
@@ -141,6 +144,20 @@ class TestPathSettings:
|
||||
settings = _make_settings()
|
||||
assert isinstance(settings.upload_dir, Path)
|
||||
assert isinstance(settings.prompt_dir, Path)
|
||||
assert isinstance(settings.log_dir, Path)
|
||||
assert isinstance(settings.database_backup_dir, Path)
|
||||
|
||||
|
||||
def test_configure_logging_writes_rotating_file_logs_to_configured_directory(tmp_path):
|
||||
log_dir = tmp_path / "logs"
|
||||
settings = _make_settings(log_dir=log_dir, log_file_name="app.log")
|
||||
|
||||
configure_logging(settings)
|
||||
|
||||
root_handlers = logging.getLogger().handlers
|
||||
file_handlers = [handler for handler in root_handlers if isinstance(handler, RotatingFileHandler)]
|
||||
assert file_handlers
|
||||
assert Path(file_handlers[0].baseFilename) == log_dir / "app.log"
|
||||
|
||||
|
||||
class TestWorkerReliabilitySettings:
|
||||
|
||||
@@ -34,7 +34,22 @@ async def test_distinct_urls_produce_distinct_engines_and_eviction_is_targeted()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disposing_an_unregistered_url_is_a_noop():
|
||||
await dispose_engine("sqlite+aiosqlite:///./.registry-test-never-created.db")
|
||||
never_created = "sqlite+aiosqlite:///./.registry-test-never-created.db"
|
||||
engine_a = get_engine(URL_A)
|
||||
engine_b = get_engine(URL_B)
|
||||
factory_a = get_session_factory(URL_A)
|
||||
factory_b = get_session_factory(URL_B)
|
||||
|
||||
await dispose_engine(never_created)
|
||||
await dispose_session_factory(never_created)
|
||||
|
||||
assert get_engine(URL_A) is engine_a
|
||||
assert get_engine(URL_B) is engine_b
|
||||
assert get_session_factory(URL_A) is factory_a
|
||||
assert get_session_factory(URL_B) is factory_b
|
||||
|
||||
await dispose_session_factory(URL_A)
|
||||
await dispose_session_factory(URL_B)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from transcription.config import Settings
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -97,3 +100,93 @@ def test_canonical_authority_references_are_present():
|
||||
if absent:
|
||||
missing[relative_path] = absent
|
||||
assert missing == {}
|
||||
|
||||
|
||||
def _declared_env_example_keys(*, include_commented: bool) -> set[str]:
|
||||
text = _read(".env.example")
|
||||
pattern = r"^\s*#?\s*([A-Z0-9_]+)\s*=" if include_commented else r"^\s*([A-Z0-9_]+)\s*="
|
||||
return {match.group(1) for match in re.finditer(pattern, text, flags=re.MULTILINE)}
|
||||
|
||||
|
||||
def _active_env_example_values() -> dict[str, str]:
|
||||
text = _read(".env.example")
|
||||
return {
|
||||
key: value.strip()
|
||||
for key, value in re.findall(r"^\s*([A-Z0-9_]+)\s*=\s*(.*)$", text, flags=re.MULTILINE)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_env_path_value(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
return normalized
|
||||
|
||||
|
||||
def test_env_example_keys_match_runtime_settings_contract():
|
||||
"""Guard against .env.example drift from Settings keys."""
|
||||
declared = _declared_env_example_keys(include_commented=True)
|
||||
settings_keys = {field.upper() for field in Settings.model_fields if field != "database"}
|
||||
database_keys = {
|
||||
"DATABASE__DRIVER",
|
||||
"DATABASE__PATH",
|
||||
"DATABASE__HOST",
|
||||
"DATABASE__PORT",
|
||||
"DATABASE__DATABASE",
|
||||
"DATABASE__USER",
|
||||
"DATABASE__PASSWORD",
|
||||
}
|
||||
allowed = settings_keys | database_keys
|
||||
|
||||
missing = sorted(allowed - declared)
|
||||
unknown = sorted(declared - allowed)
|
||||
assert missing == []
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_env_example_default_values_match_settings_defaults():
|
||||
"""Uncommented .env.example entries should mirror in-code defaults."""
|
||||
defaults = Settings(_env_file=None, openrouter_api_key="placeholder-key")
|
||||
expected = {
|
||||
"HOST": defaults.host,
|
||||
"PORT": str(defaults.port),
|
||||
"LOG_LEVEL": defaults.log_level,
|
||||
"RELOAD": str(defaults.reload).lower(),
|
||||
"LOG_DIR": str(defaults.log_dir),
|
||||
"LOG_FILE_NAME": defaults.log_file_name,
|
||||
"LOG_FILE_MAX_BYTES": str(defaults.log_file_max_bytes),
|
||||
"LOG_FILE_BACKUP_COUNT": str(defaults.log_file_backup_count),
|
||||
"PROVIDER": defaults.provider.value,
|
||||
"PROVIDER_MODEL": defaults.provider_model or "",
|
||||
"DEFAULT_PROMPT_NAME": defaults.default_prompt_name,
|
||||
"ENVIRONMENT": defaults.environment,
|
||||
"DATABASE__DRIVER": defaults.database.driver,
|
||||
"DATABASE__PATH": getattr(defaults.database, "path", ""),
|
||||
"BOOTSTRAP_SCHEMA_ON_STARTUP": str(defaults.bootstrap_schema_on_startup).lower(),
|
||||
"SQLITE_CHECK_SAME_THREAD": str(defaults.sqlite_check_same_thread).lower(),
|
||||
"UPLOAD_DIR": str(defaults.upload_dir),
|
||||
"PROMPT_DIR": str(defaults.prompt_dir),
|
||||
"HOMEPAGE_DIR": str(defaults.homepage_dir),
|
||||
"DATABASE_BACKUP_DIR": str(defaults.database_backup_dir),
|
||||
"WORKER_MAX_RETRIES": str(defaults.worker_max_retries),
|
||||
"WORKER_PROVIDER_TIMEOUT_SECONDS": str(defaults.worker_provider_timeout_seconds),
|
||||
"WORKER_MIN_TRANSCRIPTION_CHARS": str(defaults.worker_min_transcription_chars),
|
||||
"WORKER_MIN_TRANSCRIPTION_LINES": str(defaults.worker_min_transcription_lines),
|
||||
"WORKER_FAIL_ON_FINISH_REASON_LENGTH": str(defaults.worker_fail_on_finish_reason_length).lower(),
|
||||
}
|
||||
active = _active_env_example_values()
|
||||
path_like_keys = {"LOG_DIR", "UPLOAD_DIR", "PROMPT_DIR", "HOMEPAGE_DIR", "DATABASE_BACKUP_DIR"}
|
||||
mismatches = {
|
||||
key: {
|
||||
"expected": _normalize_env_path_value(expected_value) if key in path_like_keys else expected_value,
|
||||
"actual": _normalize_env_path_value(active.get(key)) if key in path_like_keys else active.get(key),
|
||||
}
|
||||
for key, expected_value in expected.items()
|
||||
if (
|
||||
(_normalize_env_path_value(active.get(key)) if key in path_like_keys else active.get(key))
|
||||
!= (_normalize_env_path_value(expected_value) if key in path_like_keys else expected_value)
|
||||
)
|
||||
}
|
||||
assert mismatches == {}
|
||||
|
||||
+10
-2
@@ -8,6 +8,7 @@ from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services import ServiceBundle
|
||||
from transcription.services.sources import SourceService
|
||||
from transcription.worker import WorkerHealth
|
||||
from transcription.worker import process_next_queued_job
|
||||
from transcription.worker import run_worker_loop
|
||||
|
||||
@@ -23,6 +24,7 @@ async def test_run_worker_loop_stops_on_non_retriable_exception(monkeypatch, cap
|
||||
"""
|
||||
calls = 0
|
||||
stop_event = asyncio.Event()
|
||||
worker_health = WorkerHealth()
|
||||
|
||||
async def _fake_process_next_queued_job(*, session=None, session_factory=None, services=None):
|
||||
nonlocal calls
|
||||
@@ -34,12 +36,16 @@ async def test_run_worker_loop_stops_on_non_retriable_exception(monkeypatch, cap
|
||||
|
||||
with caplog.at_level(logging.CRITICAL):
|
||||
await asyncio.wait_for(
|
||||
run_worker_loop(stop_event=stop_event, poll_interval_seconds=0),
|
||||
run_worker_loop(stop_event=stop_event, poll_interval_seconds=0, worker_health=worker_health),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert calls == 1
|
||||
assert "Worker loop stopped after a non-retriable error" in caplog.text
|
||||
snapshot = worker_health.snapshot()
|
||||
assert snapshot.state == "failed"
|
||||
assert snapshot.error_id is not None
|
||||
assert snapshot.error_category == ErrorCategory.INTERNAL_UNEXPECTED.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -47,6 +53,7 @@ async def test_run_worker_loop_survives_retriable_exception(monkeypatch, caplog)
|
||||
"""A retriable fault is still suppressed so transient conditions do not stop work."""
|
||||
calls = 0
|
||||
stop_event = asyncio.Event()
|
||||
worker_health = WorkerHealth()
|
||||
|
||||
async def _fake_process_next_queued_job(*, session=None, session_factory=None, services=None):
|
||||
nonlocal calls
|
||||
@@ -65,10 +72,11 @@ async def test_run_worker_loop_survives_retriable_exception(monkeypatch, caplog)
|
||||
monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0)
|
||||
await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0, worker_health=worker_health)
|
||||
|
||||
assert calls == 2
|
||||
assert "Worker loop exception" in caplog.text
|
||||
assert worker_health.snapshot().state == "stopped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user