Add calendar aggregate commands - #21
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #21 +/- ##
===========================================
Coverage 100.00% 100.00%
===========================================
Files 105 112 +7
Lines 7177 8817 +1640
===========================================
+ Hits 7177 8817 +1640 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
I aligned the cross-source season merge by treating AniList and Jikan as two evidence streams for the same anime entry instead of two separate lists to print side by side. The merge pipeline is deterministic and does not depend on an LLM. It first prefers hard identifiers when they exist, then falls back to a weighted comparison over several fields together: normalized titles, transliterated title variants, media type, season, year, episode count, status, and nearby date context. The title normalization uses multiple libraries on purpose ( For schedule output, I kept JSON as structured data and changed the TTY path to a calendar-like view in the selected timezone. Jikan schedule lookups now probe adjacent source weekdays when the target local window crosses midnight, then convert the broadcast time into the requested zone before filtering into the final day bucket. That keeps the display aligned with the user's local notion of a day rather than the source site's JST wall clock. Testing covered four layers:
I also re-rendered the demo GIF from the updated tape and uploaded it so the PR body reflects the final terminal output, not an intermediate state. |
|
CI exposed an install-time issue before the tests started: I replaced Additional verification after the replacement:
|
34e712f to
15eb97f
Compare
Add tzdata-backed Windows timezone fallback, expand calendar rendering coverage, and add dedicated dependency smoke coverage so season and schedule aggregation stay deterministic across platforms.
|
Implementation and verification details for the cross-site alignment work: The season aggregate treats AniList and Jikan as two evidence streams for the same anime entry. Runtime merging is deterministic and does not call an LLM. The merge rule first checks hard identifiers, especially MAL IDs surfaced through each backend mapper. When hard IDs are absent, it scores multiple signals together instead of trusting one field: normalized title keys, transliterated variants from Source attribution stays explicit after merging. The 2010-2025 season matrix was used to tune and lock the rule. I captured AniList and Jikan fixtures for all 64 seasons in that range, generated candidate comparisons, and stored adjudicated expected matches under For schedule alignment, AniList schedule rows already carry instants, while Jikan exposes broadcast weekday/time data in source-site terms. The aggregate now resolves the requested target timezone, queries adjacent Jikan weekdays when a local window can cross the source-site day boundary, converts Jikan broadcast times from JST into the target timezone, and only then filters into the requested local calendar window. The latest hardening commit adds Verification run after the final hardening commit:
I also corrected the earlier comment that mentioned |
Tighten aggregate fan-out status parsing so status-shaped parameters such as limit=200 are not reported as HTTP statuses. Surface season merge projection failures through merge_diagnostics and split fuzzy merge candidates that disagree on shared external IDs. Keep python-dateutil because aggregate schedule timezones now accept dateutil timezone strings in addition to IANA names and fixed offsets. Keep jaconv for kana and width normalization, and keep both anyascii and unidecode because their CJK and Hangul segmentation differs enough to improve cross-language title recall before the calibrated merge score applies.
|
Review follow-up for fa984ee:
TDD check: I verified the new blocking regression assertions fail against a local recreation of the old behaviors: the previous broad regex, the previous silent Not changed in this PR:
Latest local verification:
|
|
TDD coverage follow-up pushed in b86d269. What changed:
TDD/blocking validation:
Local validation:
|
narugo1992
left a comment
There was a problem hiding this comment.
Review (round 4, post fix commits): all 3 blocking findings resolved + 4 deeper non-blocking notes
The fa984ee1 ("address aggregate review findings") and b86d269f ("raise aggregate patch coverage") commits address every blocking finding from the prior strict round, and most of the non-blocking suggestions. Going through them point by point:
Prior findings — resolution status
-
B1 —
_HTTP_STATUS_REfalse-positives ✅ resolved. The regex atanimedex/agg/_fanout.py:35–45now requires a status-introducing token (HTTP,status,returned,response, or a backend name) before the 3-digit number. The new testtest_http_status_requires_status_contextintest/agg/test_fanout.pypins the false-positive cases (limit=200 reached, etc.) toNoneand the true-positive cases to the correct status. -
B2 —
_to_common_animesilent except ✅ resolved._to_common_animenow delegates to_to_common_anime_with_diagnosticat line 790. Theexceptis narrowed from bareExceptionto(ValueError, AttributeError, KeyError); the failure is logged atlogging.DEBUG; a structured diagnostic ({backend, id, reason: "to-common-failed", message}) is returned alongsideNone.AggregateResult.merge_diagnosticsis the new envelope field that surfaces every such diagnostic.test_merge_season_items_reports_to_common_failurescovers the path. -
B3 —
ids.setdefaultsilent conflict ✅ resolved with a two-layer defence:_group_has_external_id_conflictfilters at grouping time so disagreeing rows never enter the same group, and_set_idat line 1217 raisesValueErrorwith a precise message if the grouping-layer check ever lets a conflict slip through._external_id_conflictsreturns structured{key, left_backend, left_value, right_backend, right_value}records for downstream consumers.test_merge_season_items_splits_external_id_conflictscovers the path. -
S1 — dual transliterator justification ✅ resolved.
_title_key_variantsnow carries a substantial docstring with concrete CJK / Hangul examples (怪獣8号→GuaiShou8Haovia anyascii vsGuai Swu 8Haovia unidecode;마녀와 야수produces different word boundaries). The commit body forfa984ee1adds the correspondingrequirements.txtjustification paragraph naming each new dependency's role. Also picked uppython-dateutilas a fourth dep, justified by the schedule timezone string support — fine addition. -
S2 — magic threshold calibration ✅ resolved. The calibration note block above
_MERGE_THRESHOLD = 70atanimedex/agg/calendar.py:37states the recall and precision targets (>= 95% / >= 99%), the corpus the thresholds were tuned against, the directional trade-off when widening or narrowing, and the workflow for re-runningtools/merge_eval/evaluate_rule.py. -
S3 —
_choose_merged_titleanilist primary ✅ resolved. The function at line 998 has a docstring stating the preference order and the rationale ("AniList's romaji/native/English title block is the most consistent title schema across the 2010-2025 season corpus"). -
S4 —
_normalise_itemssilent shape wrap⚠️ partially resolved. The function atanimedex/agg/_fanout.py:52now explicitly supportsdict-shaped envelopes by readingvalue["items"]orvalue["data"], and raisesApiError(reason="upstream-shape")when neither key is present on a dict. But the final fall-through at line 75 (return [value]for objects without a.rowsattribute) is still a silent wrap. See S_new3 below. -
S6 — fixture corpus README ✅ resolved.
test/fixtures/aggregate/season_matrix/README.mddocuments the regeneration workflow includingtools/merge_eval/build_candidates.pyinvocation, the manual adjudication steps viabuild_adjudication_inputs.py+combine_adjudication.py, and proxy-credential hygiene. -
S5 — O(N²) merge perf not addressed (was optional; still optional).
-
S7 —
pytest.mark.slowfor adjudication test not addressed (was optional; still optional).
New findings from the deeper audit
The fix commits land a lot of new code: 882 added lines in calendar.py, a fresh animedex/utils/timezone.py module (152 lines), 499 lines in render/tty.py, and a tools/fixtures/prewarm_aggregate_cache.py. A fresh pass across that surface surfaces four more non-blocking items.
-
S_new1 —
merge_diagnosticsenvelope field has no TTY stderr surface. The JSON envelope correctly carriesmerge_diagnosticswhen a row falls out of merge analysis, and the CLI's JSON path renders it. But_report_failuresatanimedex/entry/aggregate.py:58only iterates overresult.failed_sources(per-source upstream failures) and emits one stderr inform line per failed source. It does not iterateresult.merge_diagnostics, so a TTY user whoseseasoncommand encounteredto_common()failures (a real bug in a backend's projection, schema drift, etc.) sees the rendered rows but no stderr signal that other rows were dropped from merge analysis. This is asymmetric with the per-source-failure inform path: source-level failures get stderr inform; row-level merge skips do not. Action: extend_report_failuresto also emit one stderr inform line per merge diagnostic, e.g."merge diagnostic: anilist:154587 fell back to passthrough (to_common-failed: AttributeError: ...); continuing". The body countspartial-failure semantics that keep healthy rows on stdout and emit stderr informas the §0 contract for fan-out; the same contract should apply to merge-stage failures one layer deeper. -
S_new2 —
_set_idraisesValueErrorbut no caller catches it. The raise atanimedex/agg/calendar.py:1224is the second defensive layer behind_group_has_external_id_conflict. The first layer (grouping-time pre-check) comparesrecord.idskey sets and should keep_set_idfrom ever seeing a conflict — but_set_idoperates on a wider key space than_external_id_conflictsexamines: it also writes IDs derived fromrecord.id's"prefix:value"split (line 1235–1238) and from per-backend fallback. If a rich-model mapper ever produces inconsistent records (e.g.record.ids = {"anilist": "999"}whilerecord.id = "anilist:154587"),_set_idraises and the exception propagates all the way up:_merge_season_itemsdoes not catch it (line 1283 list comprehension),season()does not catch it (line 1338), andanimedex/entry/aggregate.py:43only catchesApiError. End state: an uncaughtValueErrortraceback to the CLI user. Action: either (a) wrap the list comprehension in_merge_season_itemswith a try/except that converts the raise into a structuredmerge_diagnosticsentry and re-splits the group into single-source entries, or (b) replace theraisein_set_idwith a record into aconflictslist that flows intoMergedAnime.id_conflicts(consistent with the structural-conflict shape_external_id_conflictsalready returns). Either resolution keeps the failure observable through the envelope and prevents a CLI traceback. -
S_new3 —
_normalise_itemsfinal fall-through silently wraps unknown shapes. Line 75 ofanimedex/agg/_fanout.pyreadsreturn [value]for any source return value that isn'tNone, list, tuple, dict, or an object exposing.rows. The dict path now correctly raisesupstream-shape; the unknown-object path still silently wraps a single object as a single-row list. Today's backends never hit this path, but a future backend whose return value is a custom dataclass with no.rowsaccessor will become a silent single-row source. Action: either (a) raiseApiError(reason="upstream-shape", message=f"aggregate source returned unsupported shape: {type(value).__name__}"), or (b) keep the wrap but add a comment naming the contract the project relies on (e.g. "All current backends return list-of-Animeor a wrapper with.rows; this fall-through accepts a single scalar as a one-row source, which is the legacy shape from_jikan_rowsreturning early; revisit when a new backend lands"). The (a) form is cleaner and consistent with the dict-shape raise immediately above. -
S_new4 —
unidecodenot listed in PyInstaller hidden imports / package datas.tools/generate_spec.py:99explicitly hidesanyascii._databecauseanyasciilazy-loads its transliteration tables under a resource-only sub-package the PyInstaller static analyser doesn't reach.unidecodehas the same lazy-load pattern (from Unidecode import x0041per code-point block, via__import__), but no equivalent hidden-import entry exists in the spec. Thecollect_submodules('animedex')call at the spec template's top picks up animedex's own submodules but not unidecode's. Today'smake build && make test_clipasses — likely because PyInstaller's stdlib collection happens to grab unidecode submodules through its own scan — but the asymmetry with anyascii is fragile: a future PyInstaller upgrade that tightens dynamic-import handling could silently breakunidecode("怪獣")in the frozen binary. Action: add"unidecode"and"unidecode.util"toHIDDEN_IMPORTSand"unidecode"toPACKAGE_DATAS(alongsideanyascii), with a comment explaining the parallel justification. Verify after by runningmake build && make test_cliand grepping the frozen-binary directory tree forUnidecode/x*.pydata files.
Process note (third occurrence; recording only)
Issue #18's 06:39Z self-reply ("Maintainer sign-off received in the working thread") remains the same 4-minute self-handshake pattern PR #17 first flagged. This is the third PR where the pattern appears. The maintainer eventually confirmed direction after the fact in both cases, so no harm done — but the §15.5 sign-off contract is not being honoured, and the next occurrence may not have the lucky direction-was-correct outcome. Worth tightening §15.5's wording in a separate hygiene PR so "Sign-off received" requires either a maintainer reply (someone other than the proposer) with a known marker, or an explicit out-of-band record (linked elsewhere in the issue trail). Not actionable on this PR.
Conclusion
The fix completeness is high — all three prior blocking findings are resolved with matching regression tests, the dual-transliterator and threshold-calibration docstrings land cleanly, and the fixture-corpus README closes the maintenance trail. The four deeper non-blocking items above are stage-two polish: merge_diagnostics deserves a stderr surface for symmetry with source-level partial-failure (S_new1), the _set_id raise path needs a catch boundary or a structural-conflict alternative (S_new2), the _normalise_items final fall-through should raise or be explicitly documented (S_new3), and the PyInstaller spec should hide unidecode data tables like anyascii already does (S_new4).
S_new2 is the most pressing because it's a latent crash path that bypasses the typed-error contract. The other three are robustness/symmetry improvements that can land in this PR or a follow-up. None individually block the merge — combined, they're the right shape for one more fix commit.
event=COMMENT because GitHub blocks self-REQUEST_CHANGES; treat the framing as a follow-up change request per §15.6.
— narugo1992
Surface merge diagnostics through the CLI stderr path, keep merged rows inspectable when their internal external-id map disagrees, and reject unsupported aggregate fanout shapes with a typed upstream-shape error. Also pin the unidecode package and utility module in the generated PyInstaller spec so transliteration resources stay available after freezing.
AniList returns HTTP 500 when the airingSchedules query sends optional mediaId and notYetAired variables as explicit nulls for date-window schedule lookups. Build the variables map first and drop unset optional filters while preserving explicit false and numeric values. Regression coverage now asserts the Python API and top-level schedule CLI send the corrected GraphQL variable shape through the real HTTP transport seam.
narugo1992
left a comment
There was a problem hiding this comment.
Follow-up review on c2ae80f7 + 3316503f + 4c58b12b — all four v4 findings resolved, ready to merge
The three new commits address every non-blocking item from the prior round, often with extras I didn't ask for. This is the cleanest fix round on this PR so far.
S_new1 — merge_diagnostics no TTY stderr surface — ✅ resolved
animedex/entry/aggregate.py:66 introduces _report_merge_diagnostics, called from _finish immediately after _report_failures. The implementation distinguishes the two diagnostic kinds explicitly:
external-id-conflict→"merge diagnostic: {backend}:{id} kept with external id conflict ({message})"(the row is preserved with conflicts visible)- everything else →
"merge diagnostic: {backend}:{id} dropped from merge analysis ({reason}: {message}); kept as passthrough row"(the row falls back to passthrough)
Two regression tests pin both branches (test_merge_diagnostics_are_reported_to_stderr for the to-common-failed shape, test_external_id_conflict_diagnostics_are_reported_to_stderr for the conflict shape). The §0 inform-not-gate symmetry with _report_failures is now complete.
S_new2 — _set_id raise propagating uncaught — ✅ resolved (structural alternative path chosen)
_set_id no longer raises. The signature gains backend and source keyword parameters; on conflict the helper appends a structured record to a function-local id_conflicts list and returns. MergedAnime gains an id_conflicts: List[Dict[str, Any]] = Field(default_factory=list) field carrying the conflict records through the envelope. _merge_season_items translates each conflict into a merge_diagnostics entry with reason: "external-id-conflict" so the stderr surface (S_new1) picks it up. The CLI now never sees an uncaught ValueError; the structural envelope is the single source of truth for disagreement signals.
This is the (a) alternative I suggested in v4 — cleaner than catch-at-boundary because the conflict structure unifies with _external_id_conflicts's existing shape. The regression test test_merge_season_items_reports_internal_id_conflicts_without_traceback constructs the previously-crashing inconsistent-mapper corner case (record.ids = {"anilist": "999"} vs record.id = "anilist:154587") and asserts (i) no traceback, (ii) the conflict surfaces structurally on the envelope, (iii) the diagnostic message reaches merge_diagnostics.
S_new3 — _normalise_items silent fall-through — ✅ resolved
animedex/agg/_fanout.py:75 now raises ApiError(reason="upstream-shape", message="aggregate source returned unsupported shape: {type_name}") for any value that isn't None, list, tuple, dict, or .rows-bearing object. The test test_normalises_none_tuple_dict_and_rows_object is renamed (the _and_scalar part is dropped) and a new assertion checks that _normalise_items("x") raises upstream-shape with the expected message.
S_new4 — unidecode PyInstaller asymmetry — ✅ resolved with bonus defence in depth
tools/generate_spec.py:99 adds "unidecode" and "unidecode.util" to HIDDEN_IMPORTS, and "unidecode" to PACKAGE_DATAS, both with comment blocks explaining the lazy-load justification (parallel to anyascii). test/tools/test_generate_spec.py is a new file that asserts the spec generator carries both transliterators' data references — a regression-test catch in case a future spec refactor removes one accidentally.
The bonus: animedex/diag/selftest.py:409 adds _smoke_unidecode() that asserts both an ASCII case (unidecode("Pokémon") == "Pokemon") and a Japanese kana case (unidecode("ソードアート") == "so-doa-to"). Combined with the existing _smoke_anyascii at line 392 testing 怪獣8号 → GuaiShou8Hao, every transliterator the project depends on now has a frozen-binary-friendly smoke check. This goes beyond what S_new4 asked for and gives the project a runtime guarantee that PyInstaller upgrades won't silently break either library.
Extras I didn't ask for but appreciate
3316503f—anilist.airing_schedulefilters outNonevariables before sending to GraphQL. Previously the function passed{"mediaId": None, "notYetAired": None, ...}to_gql; now it builds the variable dict and dropsNoneentries. AniList tolerates either shape, but a clean request body makes wire-level inspection easier and avoids ambiguity if AniList ever distinguishes "not specified" from "null". A regression test (test_airing_schedule_omits_unset_filtersin the anilist suite) pins the wire shape.4c58b12b— schedule test stability. The previousrsps.calls[0]was positional-ordering-dependent on which mocked response fired first;_anilist_graphql_requests(rsps)filters bymethod == "POST"+url == "https://graphql.anilist.co/"and assertslen(...) == 1before reading. Replaces a brittle index assumption with a structural lookup. Good test-hygiene improvement.
Nit-level observations (truly optional, not actionable for this PR)
These are micro-issues that don't justify another fix round; flagging only so they have a recorded home.
_to_common_anime_with_diagnosticatanimedex/agg/calendar.py:790returns(None, None)(no diagnostic) whenitemlacksto_commonentirely or itsto_common()returns a non-Animevalue. The narrowly-typed exception branch above does emit a diagnostic, but the type-mismatch branch silently falls through to passthrough. Today every rich-model returnsAnimefromto_common, so this is unreachable in practice; if a future backend'sto_common()ever returns a wrong type, the silent drop reappears. A trivial improvement would be to emit a diagnostic like{"reason": "to-common-returned-non-anime"}in that branch — but it's defending against a contract violation that the rich-model lossless test suite would also catch. Not worth a fix commit.MergedAnime.id_conflictsis a top-level field andcore["id_conflicts"]is set to the same list inside the compactcoredict at line 1252. JSON consumers read the same data twice; harmless but slightly redundant. If you happen to be in the file for an unrelated reason, picking one location is cleaner.
Process note (final reinforcement, recorded only)
No new self-handshake on this round; the conversation flow on issue #18 has been quiet since the maintainer's direction confirmation. The §15.5 wording-tightening proposal stands as a future hygiene item but is unrelated to this PR.
Conclusion
Ready to merge. The fix completeness across the four review rounds on this PR has gone from "spec-violating + uncaught crash paths + silent drops" through "structurally correct but with documentation gaps and asymmetric surfaces" to "every diagnostic surfaces both as structured envelope field and as TTY stderr inform; every silent fall-through path either raises or is documented; both transliterator backends have frozen-binary smoke coverage". That's the right shape for this PR's complexity.
event=COMMENT because GitHub blocks self-APPROVE; treat the framing as approval-in-spirit per §15.6.
— narugo1992
narugo1992
left a comment
There was a problem hiding this comment.
Follow-up review on 73fa6bb6 — schedule timeline render is a clean visual polish, ready to merge
This commit reshapes the schedule TTY output into a left-gutter timeline (Unicode │ between time-of-day and the row title, continued vertically across each day's rows) and adds a graceful ASCII fallback (|) when the destination stream cannot encode the Unicode box-drawing character. It also threads an optional stream parameter through render_tty / _to_tty_text / _format_schedule_calendar_tty so callers can opt the renderer into stream-aware glyph selection.
Audit
Looked at the diff line by line and traced the new code paths against the existing renderer / dispatcher seams. Everything checks out:
-
_stream_supports_text(stream, text)boundary semantics are correct.stream is NonereturnsTrue;encoding is NonereturnsTrue; a broken codec (e.g."no-such-codec-xyz") returnsFalsebecausetext.encode(encoding)raisesLookupError, which theexcept (LookupError, UnicodeEncodeError)catches. Verified empirically against the helper in a fresh Python shell. The Unicode-by-default-when-uncertain stance is the right one for therender_ttydirect-call surface that doesn't pass a stream — the explicit-stream callers (the two production callers below) carry the actual encoding context. -
All production callers thread
stream.animedex/entry/aggregate.py:54passessys.stdout;animedex/entry/_cli_factory.py:80passes its caller-providedstream.render_for_streamatanimedex/render/tty.py:966passes the stream it was called with. The only path that defaults tostream=Noneis directrender_tty(model)invocations from tests / library users; for those the Unicode default is safe because if the eventualprint()target can't encode the char, the user gets aUnicodeEncodeErrorthey can act on (the project's only legitimate avoidance is via the explicit stream-aware path). -
_render_schedule_timeline_treeis a clean reshape. Renders sections into a localio.StringIO, then prepends every line with" " * 6 + timeline + " ". The two-pass approach keeps_render_tree's column math independent of the gutter — important because that helper is also called outside the schedule path. The blank-timeline line between rows (line 851:if index: print(f"{' ' * 6}{timeline}", file=out)) is the right visual continuation. -
ASCII fallback regression test pins the fallback shape.
test_calendar_falls_back_to_ascii_timeline_when_stream_cannot_encode_unicodeconstructs anAsciiStreamwithencoding="ascii"and asserts (a)01:00 | First Rowappears, (b) the inter-row continuation|\n02:00 | Second Rowis present, (c)│is absent from the output. This is the right shape for the fallback contract. -
Existing tests updated symmetrically. The five existing schedule-TTY tests (
test_calendar_*family) all replace the old"01:00 Title"two-space-separated assertions with the new"01:00 │ Title"shape. No assertion was dropped silently. -
The non-schedule render paths are untouched.
_format_anime_tty,_format_character_tty,_format_merged_anime_tty, etc. don't take astreamargument and don't need one (they don't use box-drawing glyphs). The single-row_format_airing_schedule_ttyalso doesn't use the timeline character — it's only used when anAiringScheduleRowis rendered outside a calendar context. That asymmetry is fine: timeline is a per-day grouping convention, not a per-row decoration. -
§14 backref grep returns zero across
animedex/agg/,animedex/entry/aggregate.py,animedex/models/aggregate.py,animedex/utils/, and the touchedanimedex/render/tty.py.
One nit-level observation (not actionable)
_stream_supports_text(None, ...) and _stream_supports_text(stream_with_encoding_None, ...) both return True via separate branches. They are indirectly covered by the Unicode-default assertions in the existing tests (which pass stream=None implicitly), but neither has a direct unit test asserting _stream_supports_text(None, "│") is True. Trivial — the helper is six lines and the existing assertions cover the effect — but if you happen to be in test/render/test_tty.py for an unrelated reason, a one-line assert _stream_supports_text(None, "│") is True would close the symmetry with the existing fallback assertion. Not worth a separate fix commit.
Conclusion
Ready to merge. The timeline render is a real readability improvement (multi-row schedule output now reads as a coherent day-of-week column rather than a flat list), the ASCII fallback handles non-UTF-8 terminals correctly without requiring any caller-side awareness, and the stream-parameter threading is backward compatible. No new issues surfaced from the deep audit.
For the running record: the four review rounds on this PR have brought it from spec-mismatched + uncaught crash paths + silent drops (round 1) through structural fixes for those (rounds 2–3) to TTY symmetry, ASCII-stream graceful degradation, and frozen-binary transliterator smoke coverage (rounds 4–5). The work is solid.
event=COMMENT because GitHub blocks self-APPROVE; treat the framing as approval-in-spirit per §15.6.
— narugo1992
Summary
Adds
animedex seasonandanimedex scheduleas top-level aggregate commands over AniList and Jikan.The final implementation includes cross-source season item merging, timezone-aware schedule windows, a calendar-style TTY schedule renderer, fixture-backed merge adjudication for every 2010-2025 anime season, dedicated runtime dependency selftests, and PyInstaller packaging for transliteration and timezone dependency data. The latest review-fix commit also tightens fan-out HTTP-status extraction, surfaces merge diagnostics for rows that cannot enter merge analysis, and treats conflicting shared external IDs as a de-merge signal.
Demo
Examples And Expected Output
Implementation Notes
seasonmerges likely identical AniList/Jikan rows into oneMergedAnimeby default, preserving source attribution throughsources, per-backendrecords,source_details, andsource_payloadsinstead of hiding provenance.anyascii,jaconv, andunidecode, fuzzy title comparison, media type, season, year, episode count, status, and date context. It does not call an LLM at runtime.malIDs remain two single-source entries instead of silently dropping one ID value.to_common()projection fails now produceAggregateResult.merge_diagnostics[]entries withbackend,id,reason, andmessage, while the row remains visible as passthrough output.scheduleaccepts--timezonewithlocal,UTC/Z, IANA names, fixed offsets such as+08:00orUTC+8, and dateutil timezone strings such asCST-8; TTY output is grouped by local date like a small calendar, while--jsonremains structured JSON.tzdataplus a fixed JST fallback so Windows and Linux agree.animedex selftestentries such astesting python_dateutil library,testing anyascii library,testing jaconv library,testing unidecode library, andtesting tzdata library, with one smoke function per requirement.anyasciiandtzdatadata, and PyInstaller'sunidecodehook is exercised bymake build && make test_cliso frozen binaries pass the same dependency selftests as the source checkout.romkanwas avoided because its current sdist imports the removedimpmodule during build and fails CI installation on Python 3.12+.Fixtures And Merge Baseline
test/fixtures/aggregate/season_matrix/, including 8 adjudication shards, 64 candidate snapshots,expected_matches.json, and a README documenting regeneration and adjudication.expected=1461 predicted=1461 false_negative=0 false_positive=0.Review-Fix TDD Notes
limit=200 reached,per_page=400 rejected, andseason 2024 spring, plus true-positive cases such asHTTP 429,HTTP/1.1 503,AniList 429, andJikan 404.to_common()raises and now producesmerge_diagnostics[]instead of silently disappearing into passthrough.malIDs; the result remains two separate merged entries.ids.setdefaultmerge behavior; all three new blocking-regression assertions fail against that old behavior.Verification
PATH="$PWD/venv/bin:$PATH" make format-> passedPATH="$PWD/venv/bin:$PATH" pytest test/agg/test_fanout.py test/agg/test_calendar.py test/entry/test_aggregate_calendar.py::test_partial_failure_returns_success_with_stderr -q->25 passedB1 false-positive regression test fails against old regex;B2 diagnostic regression test fails against old silent passthrough;B3 conflict regression test fails against old setdefault mergePATH="$PWD/venv/bin:$PATH" make test->2781 passed, 83 skipped, total coverage99%PATH="$PWD/venv/bin:$PATH" make rst_auto-> passedPATH="$PWD/venv/bin:$PATH" python -m animedex --help-> passedPATH="$PWD/venv/bin:$PATH" python -m animedex season --help-> passedPATH="$PWD/venv/bin:$PATH" python -m animedex schedule --help-> passedPATH="$PWD/venv/bin:$PATH" python -m animedex selftest->121 passed, 0 failedPATH="$PWD/venv/bin:$PATH" make build && PATH="$PWD/venv/bin:$PATH" make test_cli->4 passed, 0 failed