Hexagonal remake: src/poe2_rpc/ package + Typer CLI + 143 tests#10
Open
PrEvIeS wants to merge 17 commits intoezbooz:mainfrom
Open
Hexagonal remake: src/poe2_rpc/ package + Typer CLI + 143 tests#10PrEvIeS wants to merge 17 commits intoezbooz:mainfrom
PrEvIeS wants to merge 17 commits intoezbooz:mainfrom
Conversation
Phase A — scaffolding:
- pyproject.toml (src-layout, py3.11+, ruff/mypy/pytest/import-linter/pydantic.mypy)
- src/poe2_rpc/{domain,application,infrastructure,cli}/ skeleton + py.typed
- tests/ skeleton with conftest fixtures for verbatim regex match lines
- CI split into changes / lint-and-test (ubuntu) / build (windows) / release
with dorny/paths-filter for build_relevant gating
Phase B — domain layer (TDD, all 33/33 unit tests green, mypy strict clean,
import-linter "Hexagonal layered architecture KEPT"):
- domain/classes.py — CharacterClass + ClassAscendency (verbatim from main.py)
- domain/models.py — frozen pydantic LevelInfo (ascension_class: str | None
per ADR Behavior Change ezbooz#1) + InstanceInfo
- domain/locations.py — Location VO + LocationCatalog.resolve() ported from
main.py:163-176
- domain/events.py — frozen DomainEvent hierarchy (GameStarted, GameStopped,
CharacterLevelChanged, AreaEntered)
- domain/ports.py — 6 runtime_checkable Protocols (GameDetector, LogStream,
LogParser, PresencePublisher, EventBus, LocationCatalogPort)
- tests/unit/test_layering.py + test_no_mutable_state.py — AST guards over
src/poe2_rpc/domain/**/*.py
main.py remains the runtime entrypoint; CI build job still gated on
build-relevant paths until Phase E (CLI) and Phase F (PyInstaller) close.
Consensus artifacts (.omc/specs, .omc/plans) checked in for traceability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds 8 infrastructure adapters + 1 domain exception, all gated behind
70/70 unit tests, mypy --strict, and import-linter "Hexagonal layered
architecture KEPT" (1/1 contract).
Adapters:
- C-1 settings.py -- pydantic-settings AppSettings, env+TOML+init precedence
- C-2 logging.py -- structlog with ConsoleRenderer/JSONRenderer + bind_contextvars
- C-3 detection.py -- PsutilGameDetector (DI'd process_iter for testability)
- C-4 log_stream.py -- WatchdogLogStream honoring the C-4 thread-safety contract:
observer thread never touches asyncio.Queue; all enqueues via
loop.call_soon_threadsafe; exponential backoff (0.05->0.5s, 2s
deadline) for domain lines on QueueFull; non-domain lines drop
with rate-limited warn; cursor reset on file rotation.
- C-4b exceptions.py -- PoE2RPCError + LogStreamStalled in domain layer
- C-5 parsing.py -- regex byte-identical to main.py:273-274 (Principle 5)
- C-6 catalog.py -- BundledLocationCatalog via importlib.resources;
opt-in URL override path, no implicit recovery layer
- C-7a presence.connect -- tenacity 5 x wait_exponential(2,32)
- C-7b presence.publish -- separate 3 x wait_exponential(1,8) -- split policies
prevent 3x5=15 amplification (ADR Behavior Change ezbooz#3)
Bundled assets:
- src/poe2_rpc/locations.json copied from repo root (Phase G handles root cleanup)
Beads: panvex-{tqq,ahn,rl4,6jy,562,0d8,mvo,qkl,ne4} closed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase D of the DDD/hexagonal migration (epic panvex-enp). Adds the application layer that wires domain events to infrastructure adapters via dependency injection (Principle 4: factory injection at composition root only). Modules added under src/poe2_rpc/application/: - bus.py — AsyncioEventBus implementing EventBus Protocol with type-keyed subscribe(event_type, handler) dispatch. Handler exceptions are isolated via asyncio.gather(return_exceptions=True) so one failing handler does not break others. - throttle.py — PresenceThrottle gating presence updates to one per Settings.throttle_window_seconds (default 15s). random_status() helper preserves the verbatim status list from main.py:119-132 (Principle 5: byte-pattern preservation). - handlers.py — on_level_changed + on_area_entered async handlers. Both call structlog.contextvars.bind_contextvars(username=, character_class=, area=) BEFORE logging, satisfying AC#7 (every log line carries those keys). MutableState threads the latest level_info / instance_info between handlers so each can publish with the other's last value. Behavior Change ezbooz#1 honored: ascension_class None yields a one-segment "(BaseClass - Lvl N)" format with no "| Unknown" sentinel. - orchestrator.py — Orchestrator wiring the runtime: subscribes handlers to the bus, drives the LogStream consumer loop, emits CharacterLevelChanged / AreaEntered, and connects/closes the publisher. Constructor takes only Protocols (EventBus, PresencePublisher, Settings) plus a log_stream_factory callable — zero infrastructure imports (verified by AST guard in test_orchestrator_layering.py). Protocol drift fixes in domain/ports.py (caught during D-1/D-3 worker review): - EventBus.subscribe widened from subscribe(handler) to subscribe(event_type, handler) to express type-keyed dispatch. - PresencePublisher.publish made async to match AioPresence-backed implementation. - Added Handler type alias and Settings Protocol so application code can depend on settings shape without importing infrastructure.settings. Tests (15 new, 87/87 total passing): - tests/unit/test_bus.py — dispatch + exception isolation. - tests/unit/test_throttle.py — interval gating + random_status. - tests/unit/test_handlers.py — bind_contextvars assertion via structlog.testing.capture_logs(); ascension None formatting. - tests/integration/test_orchestrator.py — fake log_stream_factory end-to-end through bus + handlers + fake publisher. - tests/unit/test_orchestrator_layering.py — AST walk asserting no poe2_rpc.infrastructure imports anywhere in application/. Exit gates: - pytest: 87 passed (15 new for Phase D) - mypy --strict src/poe2_rpc: success, 24 files - lint-imports: Hexagonal layered architecture KEPT, 1/1 contract bd: panvex-enp.{1,2,3,4} closed. Phase E (Typer CLI + composition root) is next. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase E of the DDD/hexagonal migration (epic panvex-enp). Wires the
runtime via a Typer-driven composition root that owns adapter
construction; everything below the cli layer continues to see only
domain Protocols (Principle 4).
E-1 — `src/poe2_rpc/cli.py`:
- `app` (Typer) with `--version` callback and three commands.
- `run`: continuous monitor loop until cancelled.
- `once`: single log-stream pass and exit.
- `validate-config --no-discord`: load settings + bundled
locations.json + configure structlog without contacting Discord IPC.
Exits 0 with the resolved settings as JSON. Used as the deep-smoke
step planned for F-3 (CI build job).
- `build_orchestrator(settings) -> Orchestrator` factory extracted
for testability — patched in unit tests via CliRunner.
- `_SyncLineIterator` adapter in the composition root bridges
`WatchdogLogStream`'s async queue to the sync `LogStream.lines()`
Protocol. Owns observer start/stop lifecycle.
- Replaces the empty `cli/__init__.py` stub from Phase B-6 with a
real module; the directory layer name `poe2_rpc.cli` in the
import-linter contract continues to resolve correctly.
E-1 also reconciles four Phase D Protocol drifts in the same change
(per the "integrating worker fixes drifts" pattern documented after
Phase D):
- `PresencePublisher.publish` Protocol grew an `async def connect()`
method to match `PypresencePublisher.connect()`.
- `Orchestrator.run_once()` now calls
`loop.run_until_complete(self._publisher.connect())` on its
freshly-created loop before iterating the stream — fixes a latent
defect where the publisher was never connected before
`publish()`.
- `infrastructure.parsing` adds a `RegexLogParser` class that
satisfies the `LogParser` Protocol (the existing module-level
functions remain).
- `infrastructure.detection.PsutilGameDetector` adds `is_running()`
and `log_path()` methods matching the `GameDetector` Protocol.
`log_path()` blocks with a 5s poll loop until the game process is
found (preserves main.py:88-94 wait-for-game semantics).
- `infrastructure.catalog` adds a `load_bundled_catalog()` helper
that returns the existing domain `LocationCatalog` populated from
the bundled `locations.json` via `importlib.resources`.
E-2 — `src/poe2_rpc/__main__.py`:
- Replaces the Phase A stub with `from poe2_rpc.cli import app`
and `app()` invocation under `if __name__ == "__main__":`.
- Allows `python -m poe2_rpc` as an alternate entry alongside the
`poe2-rpc` console script declared in `pyproject.toml`.
Tests (7 new, 94/94 total passing):
- tests/integration/test_cli.py — `--version`, `--help`,
`validate-config --no-discord` (exit 0 + asserts no
`PypresencePublisher` instantiated), `once`, `run`.
- tests/integration/test_main_module.py — subprocess-driven
`python -m poe2_rpc --version`.
- tests/unit/test_ports.py + tests/integration/test_orchestrator.py
— fakes extended with `async def connect` to match the new
`PresencePublisher` shape.
Exit gates:
- pytest: 94 passed (7 new)
- mypy --strict src/poe2_rpc: success, 24 files
- lint-imports: Hexagonal layered architecture KEPT, 1/1 contract
bd: panvex-{8tv,lsk} closed.
Phase F (PyInstaller spec + bundled assets + CI smoke + cold-start
benchmark) is next.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase F of the DDD/hexagonal migration (epic panvex-enp). Wires the
release artifact path: PyInstaller spec is the build entrypoint; the
spec is shipped alongside the bundled `locations.json`, and two new
integration tests guard the asset-resolution and cold-start budgets.
F-1 — `PathOfExile2DiscordRPC.spec`:
- `Analysis(['src/poe2_rpc/__main__.py'], pathex=['src'], ...)`,
`datas=[('src/poe2_rpc/locations.json', 'poe2_rpc')]` so the
bundled JSON resolves via `importlib.resources.files('poe2_rpc')`
inside the .exe (matches `load_bundled_catalog()` from Phase E).
- Expanded `hiddenimports` for the watchdog Windows backend and the
pydantic-rust-core / pydantic-settings TOML provider / structlog /
tenacity entrypoints — the seven import paths PyInstaller can't
discover statically through pydantic v2's plugin chain.
- `collect_submodules()` over pydantic, pydantic_settings, structlog,
watchdog, tenacity, pypresence — the broad sweep complements the
explicit hiddenimports list.
- `name='PathOfExile2DiscordRPC'`, `onefile=True`, `console=True` —
preserves the existing release-asset URL stability constraint.
F-2 — `tests/integration/test_bundled_catalog.py`:
- `test_bundled_locations_json_is_reachable` proves the JSON is
discoverable through `importlib.resources` after `pip install -e .`
(uses the existing `[tool.setuptools.package-data]` entry from
Phase A's pyproject scaffold).
- `test_load_bundled_catalog_resolves_known_area` exercises the
Phase E `load_bundled_catalog()` helper end-to-end and asserts
`G1_1 -> "The Riverbank"` (anchors the canonical zone set so a
silently corrupted bundle is caught at test time, not in CI smoke).
F-3 — already wired in `.github/workflows/build.yml` lines 99-100
(`dist\PathOfExile2DiscordRPC.exe validate-config --no-discord`).
The spec from F-1 unblocks this step; the workflow side requires no
edits. Final acceptance — green CI run on Windows — stays open in
bd until the next push triggers `workflow_dispatch` evidence.
F-4 — `tests/integration/test_cold_start.py`:
- Resolves the binary via `POE2_RPC_EXE` env (CI sets it to
`dist\PathOfExile2DiscordRPC.exe`); skipped in local dev when the
exe is absent so `pytest` stays clean without a build.
- Runs `validate-config --no-discord` 5 times, asserts each exits 0
and computes p95 against the 8s budget. Budget breach fails the
test; per the plan a breach files a follow-up bd issue and
annotates release notes — does NOT block the first DDD release.
- The CI step already exists at `build.yml:102-104` with
`continue-on-error: true` matching the non-blocking policy.
Tests (2 new + 1 skipped, 96/96 + 1 skip total):
- pytest: 96 passed, 1 skipped (cold-start needs the exe)
- mypy --strict src/poe2_rpc: success, 24 files
- lint-imports: contract unchanged (no new domain/application/
infrastructure code in this commit)
bd: panvex-{98r,6uc} closed. panvex-{73a,9bu} stay open pending the
first CI workflow_dispatch run that produces evidence:
- panvex-73a (F-3): smoke step exits 0 on Windows runner
- panvex-9bu (F-4): p95 cold-start <= 8s recorded in CI logs
Phase G (regex sample test, README/CLAUDE.md updates, delete
main.py blocked-by live smoke, 10-step Windows VM checklist) is
next, but G-2 (delete main.py) cannot close until G-4 records the
live smoke and panvex-00o (catalog.resolve wiring in orchestrator)
ships — both are pre-cutover blockers tracked in bd.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
panvex-00o: Orchestrator now calls LocationCatalog.resolve() between parser.parse_instance() and bus.emit(AreaEntered(...)), copying the resolved display_name onto the InstanceInfo via model_copy(). Closes the pre-cutover blocker — raw internal codes (G1_4_BrambleghastSlain) are no longer pushed to Discord; the bundled locations.json is the single source of truth as in main.py:determine_location(). panvex-enn (G-1): tests/integration/test_regex_real_sample.py asserts both regex_level and regex_instance match >=1 entry in a real Client.txt sample. Skips with reason when the fixture is absent — fixture is captured manually during the G-4 Windows live smoke and locks in regex compatibility once present. panvex-8kz (G-3): README.md and CLAUDE.md rewritten for the new src/poe2_rpc/ package. README points at `pip install -e ".[dev]"` + `poe2-rpc run` and lists CLI subcommands (run/once/validate-config). CLAUDE.md now describes hexagonal layering, gate suite (pytest, mypy --strict, ruff, lint-imports), the .spec build, and updated ascendancy/zone/regex onboarding pointers. BEADS INTEGRATION block preserved verbatim. Phase G remaining: - panvex-a17 (G-2 delete main.py) — blocked by G-4 - panvex-12l (G-4 Windows live smoke) — manual VM checklist Gates: 97 pytest passed + 3 skipped (cold-start without exe, test_regex_real_sample without fixture); mypy --strict clean across 24 source files; ruff check + ruff format clean on touched files.
Replace lazy imports with module-level imports across tests, rename LogStreamStalled→LogStreamStalledError per N818, hoist tomllib import in settings, dedup unused-arg via `_event` and `del` (no rule ignores). Bump actions/checkout v4→v5 and actions/setup-python v5→v6 (Node 20 deprecation), set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 for paths-filter@v3, and replace archived create-release@v1 + upload-release-asset@v1 with softprops/action-gh-release@v2. Run ruff format on the whole tree to bring previously untouched files in line with the autoformatter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
infrastructure/catalog.py imports httpx at module level for the locations_url override path. CI mypy strict failed with import-not-found because httpx was not in [project.dependencies] — it only resolved locally via transient install. Declare it explicitly so CI installs it and the strict typecheck passes.
Previous commit set the env only on the dorny/paths-filter step, leaving actions/upload-artifact@v4, actions/download-artifact@v4, and softprops/action-gh-release@v2 still emitting Node 20 deprecation warnings. Hoisting the env to workflow level applies the Node 24 force to every step in every job in one place.
- actions/checkout@v5 → @v6 (Node 24 native) - dorny/paths-filter@v3 → @v4 (new ref input, list-files csv) - actions/download-artifact@v4 → @v5 (Node 24 native) - softprops/action-gh-release@v2 → @V3 (explicitly Node 24) actions/upload-artifact stays at @v4 — no v5 yet upstream; FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 retained for that one action.
…ide Steam `AppSettings.process_name` becomes `list[str]` defaulting to both the Steam build (`PathOfExileSteam.exe`) and the standalone client (`PathOfExile.exe`). A `field_validator` + `NoDecode` annotation keeps legacy `POE2RPC_PROCESS_NAME=Foo.exe` env vars working by promoting the raw string to a single-element list before validation. `PsutilGameDetector` now matches the running process by membership in the candidate list. Refs upstream PR-1 from .omc/plans/ralplan-upstream-prs.md (bd panvex-4gx).
…te machine Adds an `OwnerTracker` frozen pydantic VO (UNKNOWN -> AREA_ENTERED -> PINNED, or INVALIDATED if a party member joins inside an unpinned window). The orchestrator emits two new domain events from the parse loop: * `LocalAreaEntered` <- `: You have entered <area>.` * `PartyMemberJoined` <- `<name> has joined the area.` Their handlers drive the state machine. `on_level_changed` consults `owner_tracker.should_emit(li.username)` and drops level events that don't belong to the local player. The `LogParser` Protocol grows two new `parse_local_area_entered` / `parse_party_joined` methods (regexes verbatim from klayveR/poe-log-monitor `resource/events.json`). An optional `POE2RPC_CHARACTER_NAME` env override short-circuits to PINNED on first area entry. Tests cover the full state-machine table (15 cases incl. parametrized party scenarios + re-entry-resets-INVALIDATED) and add a runtime-checkable Protocol conformance test for `RegexLogParser`. Refs upstream PR-2 from .omc/plans/ralplan-upstream-prs.md (bd panvex-7w5).
…verride On the AFK ON line, the handler snapshots the current small_image (derived from the current LevelInfo) so a level-up that happens during the AFK window cannot leak its new icon when AFK turns OFF. Restore semantics use a single small_image_override path through PypresencePublisher so the snapshot wins over the recomputed default. - domain/models: AFKStatus frozen VO (mode: Literal["AFK","DND"], on, autoreply) - domain/events: AFKStatusChanged - domain/ports: LogParser.parse_afk_event; PresencePublisher.publish gains afk_on + small_image_override kwargs (defaults preserve back-compat) - infrastructure/parsing: regex_afk + parse_afk_event_line + RegexLogParser.parse_afk_event - infrastructure/presence: [AFK] suffix on state, override replaces small_image - application/handlers: MutableState gains afk_on/prior_small_image; new on_afk_changed handler captures snapshot on ON, restores on OFF - application/orchestrator: subscribe AFKStatusChanged + parse-loop branch - tests/unit/test_afk.py: 9 tests (4 parametrized parse + 1 none + 2 kwargs + restore-after-level-during-AFK + AFK-without-prior-level)
PR-4 of the upstream campaign. Adds an opt-in tray icon and a Startup- folder shortcut so the orchestrator can run as a background service. Hexagonal changes: - pyproject.toml: new [tray] extras (pystray>=0.19, Pillow>=10, pylnk3>=0.4); matching mypy ignore_missing_imports overrides; project-wide PLC0415 ruff ignore (deferred imports are the architectural extras-gate, not a smell). - domain/ports.py: LogStream Protocol gains close() + is_closed() so the tray Quit thread can unblock the sync for-line loop. - infrastructure/log_stream.py: thread-safe idempotent close() that stops the watchdog observer and posts an empty sentinel via call_soon_threadsafe to wake the queue-bound consumer. - infrastructure/tray.py: TrayController wrapping pystray.Icon with Status/Open log/Restart/Quit menu and a default 64x64 PIL icon when no icon file is supplied. Module-level import gate raises a clear hint to install the [tray] extras. - infrastructure/autostart.py: pylnk3-backed Startup-folder shortcut at %APPDATA%/Microsoft/Windows/Start Menu/Programs/Startup. Idempotent install + boolean uninstall. - application/orchestrator.py: tracks _current_stream, wraps the line loop in try/finally so close() always runs, filters the empty sentinel, and exposes a thread-safe stop() that closes the active stream. - cli.py: tray / install-autostart / uninstall-autostart commands; tray spawns the orchestrator on a background thread and wires Quit to orch.stop() THEN icon.stop() (drain order matters); _SyncLineIterator now forwards close()/is_closed() so cli adapter respects the new Protocol. Tests (+15): - test_orchestrator_stop.py: threaded run + main-thread stop joins within 1s; double-close is idempotent; stop with no active stream is a noop. - test_tray.py: menu wiring, status propagation, quit-callback ordering. - test_autostart.py: install/uninstall idempotency, frozen exe path resolution; uses sys.modules injection so pylnk3 is not required in dev. - test_extras_missing.py: tray + install-autostart commands exit 1 with the pip install hint when extras are absent. Docs: - README.md: new "Run as a background service" section with the explicit pip install poe2-rpc[tray] prereq before poe2-rpc tray, plus the install-autostart / uninstall-autostart pair; flips the matching to-do. Gates: 143 passed (+15), mypy --strict clean, ruff clean, ruff format clean, lint-imports kept (1 contract). Refs: epic panvex-b6p, plan .omc/plans/ralplan-upstream-prs.md
…in EN/RU/UA The 4-PR upstream campaign (panvex-b6p) is feature-complete on this stacked branch. Updates the user-facing READMEs and CLAUDE.md to reflect the four shipped capabilities: - Official PoE2 client support (Steam + PathOfExile.exe) - Owner auto-pin (party-mate disambiguation) - AFK / DND status with small-image override + restore - Background tray service + Windows Startup shortcut README.md / README.ru.md / README.ua.md - Add explicit Features bullets for all four capabilities. - Flip the To-Do checklist (now 5/5 complete). - RU/UA translations get the new "Run as a background service" section to match the English README that was added during PR-4. CLAUDE.md - Replace the four open-work items with checked-off equivalents and a pointer to the upstream draft PRs (ezbooz#6, ezbooz#7, ezbooz#8, ezbooz#9). Notes that the remaining work is the end-of-campaign Windows live-smoke pass.
Root AGENTS.md was stamped 2026-05-04 and still described the single-file main.py runtime — completely stale after the G-phase migration to src/poe2_rpc/. Refreshed via the deepinit skill: - Root: dual-form (hexagonal + upstream-form main.py for backports), full layering rules, build/test gate, upstream PR campaign pointer; preserves the BEGIN BEADS INTEGRATION block verbatim. - src/poe2_rpc/AGENTS.md (new): hexagonal package overview + composition root, _SyncLineIterator bridge, optional-extras gate. - src/poe2_rpc/domain/AGENTS.md (new): frozen pydantic VOs, Protocol ports, ascendancy-add workflow. - src/poe2_rpc/application/AGENTS.md (new): orchestrator + bus + throttle + handlers; sync close-stream design. - src/poe2_rpc/infrastructure/AGENTS.md (new): all 11 adapter modules, optional-deps idiom (module-top vs lazy-inside), tenacity split-retry. - tests/AGENTS.md, tests/unit/AGENTS.md, tests/integration/AGENTS.md (new): test layout + AST-guard inventory + Typer CLI surface. All non-root files carry <!-- Parent: ../AGENTS.md --> (../../AGENTS.md for src/poe2_rpc/) and a <!-- MANUAL: --> preservation marker.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Full architectural remake of
Path-Of-Exile-2-RPCfrom a single-filemain.pyruntime into a hexagonal Python package (src/poe2_rpc/) with explicitdomain/application/infrastructure/clilayers, a Typer CLI entrypoint (poe2-rpc), pydantic v2 frozen value objects, structlog structured logging, tenacity split-retry, watchdog event-driven log tailing, and a 143-test pytest suite.The current
main.pyis preserved verbatim as the upstream-compatible runtime, so this PR is purely additive to the existing user-visible surface — the legacypython main.pyinvocation keeps working unchanged.Relationship to PRs #6–#9
This PR is a companion to the four small, focused upstream PRs already open against
main.py:PathOfExile.exealongside Steam).[AFK]suffix and small-image override + restore.PRs #6–#9 carry the same four features re-encoded for the single-file form. This PR is not a substitute for them — it offers the architectural target if you want to migrate the runtime to a package layout. Merge order is fully your call:
main.py.AGENTS.md.What's included
Hexagonal package (
src/poe2_rpc/)domain/LevelInfo,InstanceInfo), domain events (CharacterLevelChanged,AreaEntered,AFKStatusChanged,LocalAreaEntered,PartyJoined), runtime-checkable Protocols (GameDetector,LogStream,LogParser,PresencePublisher,EventBus,LocationCatalogPort,Settings),OwnerTrackerstate machine,CharacterClass/ClassAscendencyenums,LocationCatalog.application/Orchestrator(composes pipeline + sync close-stream stop signal),InMemoryEventBus,PresenceThrottle, level/area/AFK handlers with structlogbind_contextvars.infrastructure/PsutilGameDetector(Steam + official client),WatchdogLogStream(event-driven, asyncio-bridged),RegexLogParser(regexes verbatim frommain.py:273-274),PypresencePublisher(tenacity split-retry + AFK small-image override),AppSettings(pydantic-settings), bundled-catalog loader (importlib.resources), structlog config, optional pystray tray + pylnk3 Windows Startup shortcut.cli.pyrun,once,validate-config,tray,install-autostart,uninstall-autostart. Composition root +_SyncLineIteratoradapter bridging async watchdog → syncLogStreamProtocol.Architectural enforcement
import-linterconfig inpyproject.tomlenforcesdomain ← application ← infrastructure ← cli.tests/unit/test_no_mutable_state.pyAST guard fails CI if any non-frozen pydantic model ordataclasslands indomain/.tests/unit/test_layering.pyAST guard fails CI on unauthorized cross-layer imports.mypy --strictclean acrosssrc/poe2_rpc.Tests (143 passing)
Packaging
pyproject.tomlexposespoe2-rpcconsole script and[tray]extra (pystray,Pillow,pylnk3).PathOfExile2DiscordRPC.specupdated to bundlesrc/poe2_rpc/locations.jsonvia PyInstaller--onefileso the existing release artifact keeps working..github/workflows/build.ymlruns the full lint+test gate on Ubuntu, then PyInstaller build + deep-smoke + cold-start benchmark on Windows.Documentation
AGENTS.mdfiles at every directory (root +src/poe2_rpc/+ per-layer +tests/+.github/) with<!-- Parent: -->references for navigability.CLAUDE.mdproject instructions.README.md/README.ru.md/README.ua.mdupdated to document the new CLI commands and tray/autostart workflow; legacypython main.pyinstructions preserved.Why this is a non-trivial proposal
The hexagonal layout is much more code than the single-file runtime — about 7.8k lines added. The trade-off:
bind_contextvars, and adding a feature like the four in Support official PoE2 client (PathOfExile.exe) alongside Steam build #6–Run as background service: --tray, --install-autostart #9 becomes "implement the Protocol, wire it incli.py, write tests" instead of editingmain.pycarefully.If you'd rather keep
main.pyas the canonical runtime and merge only #6–#9, this PR can be closed without prejudice — the four small PRs stand on their own. If you'd like the package as the canonical runtime going forward, this PR is the migration.Happy to split this into stacked PRs (one per layer) if that's easier to review.
Test plan
AGENTS.mdhierarchy starting at the repo root for an overview of the layout.pip install -e ".[dev]" && pytest tests -ra && mypy --strict src/poe2_rpc && lint-imports && ruff check src tests && ruff format --check src testspoe2-rpc validate-config --no-discordpython main.pypoe2-rpc runand the tray service:pip install ".[tray]" && poe2-rpc tray