Compare commits
3 Commits
aadfd1cfcc
...
4ab49082c1
| Author | SHA1 | Date |
|---|---|---|
|
|
4ab49082c1 | |
|
|
5c17419c93 | |
|
|
1a2d489397 |
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_faae5c497ffeS0ofaTEfbRo2LP
|
||||||
|
timestamp: 2026-09-03T20:54:04Z
|
||||||
|
git_ref: aadfd1cf
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260903T205404Z_aadfd1cf_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue from the completed replay handoff when next steps are clear.
|
||||||
|
Honor the prior decision to park replay and begin the live-backend-first
|
||||||
|
chart E2E roadmap with the Phase A Qt/Trio lifecycle seam. First perform
|
||||||
|
the approved exhaustive local scan, then create
|
||||||
|
`wkt/live_chart_qtractor` from exact Phase 1 commit `aadfd1cf` and
|
||||||
|
implement nonblocking startup, structured shutdown, observable readiness
|
||||||
|
and completion, and provider-independent lifecycle coverage without new
|
||||||
|
interlayer mocks.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Creates the isolated worktree after classifying two stale historical
|
||||||
|
lifecycle experiments as related but not reusable. Extracts
|
||||||
|
`start_qtractor()` and `QtractorSession`, replaces process-wide SIGINT on
|
||||||
|
window close with owned Trio cancellation, publishes application
|
||||||
|
readiness at the full `_async_main()` startup boundary, and preserves
|
||||||
|
`run_qtractor()` as the blocking Qt-loop owner which unwraps the guest
|
||||||
|
outcome. A headless regression proves two sequential real Tractor
|
||||||
|
runtimes start and stop cleanly under one pytest-owned Qt application.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `piker/ui/_exec.py` - add nonblocking session startup, lifecycle
|
||||||
|
signals, owned cancellation, outcome capture, Qt cleanup, and the
|
||||||
|
blocking CLI adapter.
|
||||||
|
- `piker/ui/_window.py` - inject structured shutdown and remove normal
|
||||||
|
window-close SIGINT while cleaning the global zoom filter.
|
||||||
|
- `piker/ui/_app.py` - publish readiness after all application startup
|
||||||
|
scopes are live.
|
||||||
|
- `tests/ui/test_qtractor.py` - prove repeated real-Tractor lifecycle
|
||||||
|
startup and close under pytest-qt.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human chose exhaustive local discovery specifically to catch prior
|
||||||
|
Qt/Trio experiments, confirmed the replay branch had been pushed to
|
||||||
|
Gitea for backup, and directed continuation without another planning
|
||||||
|
pause. The inherited human-approved roadmap parks replay, prioritizes a
|
||||||
|
real Kraken chart, forbids additional interlayer mocks without a concrete
|
||||||
|
regression, and fixes `aadfd1cf` as this worktree's base. The human also
|
||||||
|
explicitly authorized the focused headless test through `nix develop`;
|
||||||
|
no real-compositor run was authorized or performed. During final review,
|
||||||
|
the human corrected nonconforming boolean layout, requested local type
|
||||||
|
annotations wherever helper return types were not obvious at the binding,
|
||||||
|
and replaced silent Qt disconnect suppression with Piker-style exception
|
||||||
|
logging.
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-09-03T20:54:04Z
|
||||||
|
git_ref: aadfd1cf
|
||||||
|
diff_cmd: git diff HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generated changes
|
||||||
|
|
||||||
|
> `git diff HEAD -- piker/ui/_exec.py piker/ui/_window.py`
|
||||||
|
|
||||||
|
Extracts nonblocking `start_qtractor()` from the blocking CLI adapter.
|
||||||
|
The returned `QtractorSession` exposes the production window and central
|
||||||
|
widget, the Trio token and cancellation scope, readiness and completion
|
||||||
|
Qt signals, the final `outcome.Outcome`, Qt-loop ownership, and an
|
||||||
|
idempotent structured shutdown request. Caller-owned Qt application
|
||||||
|
state and installed callbacks are restored when the guest completes.
|
||||||
|
`MainWindow.closeEvent()` retains geometry persistence but delegates
|
||||||
|
runtime teardown through the injected callback instead of sending
|
||||||
|
process-wide SIGINT.
|
||||||
|
|
||||||
|
> `git diff HEAD -- piker/ui/_app.py`
|
||||||
|
|
||||||
|
Publishes application readiness only after initial symbol loading,
|
||||||
|
order-mode startup, search registration, provider-search task startup,
|
||||||
|
input handler entry, and startup-status removal.
|
||||||
|
|
||||||
|
> `git diff HEAD -- tests/ui/test_qtractor.py`
|
||||||
|
|
||||||
|
Adds a provider-independent lifecycle regression which runs two real
|
||||||
|
Tractor root runtimes in sequence on pytest's unique registry. Real
|
||||||
|
window-close events drive structured cancellation while signal, outcome,
|
||||||
|
task-finalization, global-window, runtime, and shared-QApplication
|
||||||
|
assertions prove restartable teardown without an interlayer fake.
|
||||||
|
|
||||||
|
Human review then brought the generated Python into project style:
|
||||||
|
boolean connectives use dedicated vertical lines, opaque helper returns
|
||||||
|
and control-flow targets carry local annotations, and Qt disconnect
|
||||||
|
failures are logged instead of silently suppressed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Ruff passes for all four changed Python files.
|
||||||
|
- Python static compilation and `git diff --check` pass.
|
||||||
|
- The first focused run exposed the test widget's missing production
|
||||||
|
`on_win_resize()` contract; the narrow test widget was corrected.
|
||||||
|
- The focused lifecycle regression passes with two sequential real
|
||||||
|
Tractor sessions.
|
||||||
|
- The combined UI harness and lifecycle run passes: 3 tests and two
|
||||||
|
upstream Tractor `Registrar.uid` deprecation warnings.
|
||||||
|
- A post-pass attempt to disconnect a pre-existing Qt signal proxy
|
||||||
|
segfaulted during PyQt teardown and was fully reverted; it is not part
|
||||||
|
of this patch. The restored lifecycle regression passes again.
|
||||||
|
- All automated Qt execution used `--headless` and the offscreen
|
||||||
|
platform.
|
||||||
|
- The approved `nix develop` shell hook unexpectedly provisioned the
|
||||||
|
ignored worktree-local `py313/` environment; no tracked dependency or
|
||||||
|
lock files changed.
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-6-astra
|
||||||
|
service: opencode
|
||||||
|
session: null
|
||||||
|
timestamp: 2026-09-08T21:50:45Z
|
||||||
|
git_ref: 1a2d4893
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260908T215045Z_1a2d4893_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Continue implementation in SAME session/worktree
|
||||||
|
/home/goodboy/repos/piker/wkts/live_chart_qtractor. User explicitly
|
||||||
|
says proceed. No ownership takeover needed, no branch creation, no
|
||||||
|
staging/commits, no ai.skillz changes. Implement minimal prerequisite
|
||||||
|
fixes before live Kraken tests: inspect current code and fix
|
||||||
|
start_qtractor merge precedence so explicit caller registry/runtime
|
||||||
|
kwargs beat global defaults; fix PyQt6 blocking run_qtractor exec_ ->
|
||||||
|
exec; add focused offline headless lifecycle regressions proving
|
||||||
|
explicit registry selection despite stale globals and CLI adapter as
|
||||||
|
feasible. Load py-codestyle, piker-conc-expert, run-tests via tools;
|
||||||
|
read deployed test harness. Preserve existing comments and user-owned
|
||||||
|
state. Use apply_patch. Existing staged baseline is committed at
|
||||||
|
1a2d4893, .gitignore/.ai workflow state unrelated leave untouched. User
|
||||||
|
prefers typed opaque function returns, no redundant obvious
|
||||||
|
constructor types, vertical boolean operators, logging exceptions.
|
||||||
|
Run only targeted headless tests with existing py313 interpreter and
|
||||||
|
Nix wrapper (already user-authorized in this session); do not run full
|
||||||
|
suite or public network charts. Do not expand to paper mode or
|
||||||
|
readiness yet. Return changes/tests, exact remaining blockers, and any
|
||||||
|
provenance modifications. Persist prompt-io for code per skill with
|
||||||
|
correct active model identity if known, not invented old model. Keep
|
||||||
|
minimal.
|
||||||
|
|
||||||
|
## Response Summary
|
||||||
|
|
||||||
|
Fixed explicit caller kwarg precedence and the PyQt6 blocking API.
|
||||||
|
Extended real runtime lifecycle coverage and added subprocess-isolated
|
||||||
|
CLI adapter success/error regressions. Focused tests passed 3/3;
|
||||||
|
combined UI isolation and lifecycle tests passed 5/5, with only two
|
||||||
|
existing Tractor deprecation warnings per run.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `piker/ui/_exec.py`: merge order and `QApplication.exec()`.
|
||||||
|
- `tests/ui/test_qtractor.py`: stale-global and blocking regressions.
|
||||||
|
- This log and its raw companion: prompt and generated-output record.
|
||||||
|
|
||||||
|
Existing provenance entries and the service README are unchanged.
|
||||||
|
Unrelated `.gitignore` and `.ai` state are unchanged. No staging,
|
||||||
|
commits, branch creation, or ai.skillz changes were performed.
|
||||||
|
The active harness did not expose a session UUID; it is not invented.
|
||||||
|
|
||||||
|
## Human Edits
|
||||||
|
|
||||||
|
The human identified both prerequisite defects, selected this existing
|
||||||
|
worktree, restricted implementation to minimal offline coverage, and
|
||||||
|
specified code style, test environment, provenance identity, and
|
||||||
|
untouched state. These are human-directed agent edits. No direct
|
||||||
|
manual edits during this turn were observed; acceptance remains with
|
||||||
|
the human.
|
||||||
|
|
||||||
|
## Continuation Prompt: 2026-09-08T21:56:56Z
|
||||||
|
|
||||||
|
Implement next minimal safety prerequisite in
|
||||||
|
/home/goodboy/repos/piker/wkts/live_chart_qtractor. Same session user
|
||||||
|
authorized continuation. Previous agent modified piker/ui/_exec.py,
|
||||||
|
tests/ui/test_qtractor.py and added
|
||||||
|
ai/prompt-io/opencode/20260908T215045Z_1a2d4893_prompt_io.md/.raw.md for
|
||||||
|
registry precedence and Qt exec fixes. Preserve all those and unrelated
|
||||||
|
.gitignore/.ai files. No staging, commits, branch/worktree operations
|
||||||
|
or ai.skillz edits. Task: inspect production chart startup
|
||||||
|
_app._async_main -> GodWidget/load_symbols/display -> open_order_mode
|
||||||
|
-> open_ems. Thread a minimal explicit execution mode option so a chart
|
||||||
|
integration caller can require paper EMS BEFORE any brokerd spawn.
|
||||||
|
Preserve shipped default live CLI behavior (concrete existing callers).
|
||||||
|
Prefer one clear existing convention/type and defaults, no
|
||||||
|
compatibility scaffolding. Also inspect readiness provider search
|
||||||
|
start_soon: implement minimal nursery.start/task_status handshake if
|
||||||
|
appropriate to ensure application readiness follows provider search
|
||||||
|
registration, without arbitrary waits. Load piker-clearing-expert,
|
||||||
|
piker-conc-expert, py-codestyle, run-tests skills and harness. Tests
|
||||||
|
must be real structured runtime tests or narrow direct unit tests as
|
||||||
|
existing conventions permit; do not add interlayer mocking without
|
||||||
|
asking. Do not run live providers, GUI compositor or full suite. Run
|
||||||
|
relevant headless tests using existing py313 and already-authorized
|
||||||
|
nix develop. If prerequisite changes grow too broad or unsafe stop
|
||||||
|
and report exact tradeoff. Update provenance for your changes
|
||||||
|
appropriately (same logical prerequisite patch may extend newest entry
|
||||||
|
without erasing original generation). Return code paths, exact test
|
||||||
|
evidence, risks and next live chart test requirements.
|
||||||
|
|
||||||
|
## Continuation Summary
|
||||||
|
|
||||||
|
Added explicit session-wide EMS mode plumbing across `_async_main`,
|
||||||
|
`GodWidget.load_symbols`, `display_symbol_data`, `open_order_mode`,
|
||||||
|
and `open_ems`. The existing string convention and live defaults are
|
||||||
|
preserved. Invalid startup modes fail before widget access. Added the
|
||||||
|
provider-search Trio startup handshake before application readiness.
|
||||||
|
|
||||||
|
New direct unit coverage is in `tests/ui/test_chart_startup.py`.
|
||||||
|
Combined with existing Qtractor and UI-isolation tests, 9 tests passed
|
||||||
|
in 2.67s; the separate display/gap-overlay run passed 9 in 2.00s.
|
||||||
|
Warnings were limited to existing Tractor deprecations. Full forwarding
|
||||||
|
and provider registration ordering remain live-integration coverage
|
||||||
|
requirements, not claims of these narrow tests.
|
||||||
|
|
||||||
|
This continuation changed four UI production files, added the startup
|
||||||
|
test file, and appended to this provenance pair. Original generation,
|
||||||
|
`_exec.py`, `test_qtractor.py`, `.gitignore`, `.ai`, and ai.skillz were
|
||||||
|
left untouched. No staging or Git history/worktree operations occurred.
|
||||||
|
|
||||||
|
## Continuation Human Contributions
|
||||||
|
|
||||||
|
The human selected paper execution and search readiness as the next
|
||||||
|
safety prerequisite, required shipped live defaults, prohibited
|
||||||
|
interlayer mocking without approval, limited verification to headless
|
||||||
|
offline tests, and authorized appending this existing provenance pair.
|
||||||
|
These requirements directed the generated edits. No direct manual
|
||||||
|
changes during this continuation were observed; acceptance remains
|
||||||
|
with the human.
|
||||||
|
|
||||||
|
## Live Qualification Prompt: 2026-09-08
|
||||||
|
|
||||||
|
The human explicitly authorizes public Kraken market data for
|
||||||
|
xbtusd.spot.kraken, headless integration, isolated registry/config,
|
||||||
|
and explicit paper execution with no credentials or real orders.
|
||||||
|
Implement the minimal production live chart test in this same owned
|
||||||
|
worktree, preserving the uncommitted prerequisites and unrelated
|
||||||
|
.gitignore/.ai state. No takeover, staging/commit, or ai.skillz edits.
|
||||||
|
Use the authoritative live-backend roadmap, required concurrency,
|
||||||
|
clearing, Python style and test-harness skills, and existing py313/Nix
|
||||||
|
wrapper. Add explicit --live-provider=kraken opt-in; preserve original
|
||||||
|
selected-run errors. Use production GodWidget and _async_main via
|
||||||
|
start_qtractor, owned empty broker config, unique reg_addr, inherited
|
||||||
|
piker_test_dir and isolated settings. Record paper/no-brokerd and
|
||||||
|
exact owned resources where possible. Always cancel in finally and
|
||||||
|
pump Qt with a bound before widget destruction. No new interlayer
|
||||||
|
mocks, symbol switching, blanket retries, broad cleanup, or expanded
|
||||||
|
test excludes. Stop if production blockers require broader repairs.
|
||||||
|
Append accurate gpt-6-astra provenance and human network authorization
|
||||||
|
to the newest pair, preserving earlier generation.
|
||||||
|
|
||||||
|
## Live Qualification Summary
|
||||||
|
|
||||||
|
Active model remains gpt-6-astra (`openai/gpt-6-astra`). Added the
|
||||||
|
explicit live-provider option and production Kraken smoke test.
|
||||||
|
Corrected one demonstrated Kraken public-client constructor keyword
|
||||||
|
from `conf` to `config`. No other production code was changed during
|
||||||
|
this continuation. Exact new patch scope is:
|
||||||
|
|
||||||
|
- `tests/conftest.py`: six-line opt-in option.
|
||||||
|
- `tests/ui/test_live_chart.py`: new production integration test.
|
||||||
|
- `piker/brokers/kraken/api.py`: one keyword correction.
|
||||||
|
- This existing Prompt-IO pair: append-only continuation records.
|
||||||
|
|
||||||
|
Final offline targeted checks: 9 passed, 1 skipped in 2.65s. The live
|
||||||
|
run failed before the keyword fix (1 failed, 1 teardown error in
|
||||||
|
2.66s), then progressed to public history and paper EMS but failed
|
||||||
|
again (1 failed, 1 teardown error in 12.44s). Readiness preceded
|
||||||
|
rt_linked.mode assignment; Qt reported abstract paint errors and
|
||||||
|
widget teardown survivors. No successful live qualification is claimed.
|
||||||
|
Commands, original signatures, proposed minimal readiness repair,
|
||||||
|
and evidence limitations are retained in the raw companion.
|
||||||
|
|
||||||
|
Guest completion preceded widget destruction, but Tractor escalated
|
||||||
|
its own child teardown to SIGKILL and a paperboi.kraken orphan remained:
|
||||||
|
PID 752893, UID e8317c39-ebbb-4526-85f0-3440a2e6e39c, PPID 1.
|
||||||
|
No agent-issued signal or SHM cleanup was performed. Further live
|
||||||
|
runs and production repairs were stopped. The final test improvements
|
||||||
|
to failure-path SHM/endpoint accounting were checked offline only.
|
||||||
|
|
||||||
|
The human directed all safety boundaries, the public network opt-in,
|
||||||
|
the minimal scope and stop condition, and continuation of this pair.
|
||||||
|
These are human-directed agent edits; acceptance remains with the
|
||||||
|
human. Earlier provenance, prerequisite work, task-state markers,
|
||||||
|
.gitignore, .ai, and ai.skillz are unchanged. No staging or commits.
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-6-astra
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-09-08T21:50:45Z
|
||||||
|
git_ref: 1a2d4893
|
||||||
|
diff_cmd: git diff HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generated Changes
|
||||||
|
|
||||||
|
> `git diff HEAD -- piker/ui/_exec.py`
|
||||||
|
|
||||||
|
`start_qtractor()` copies global runtime defaults first and explicit
|
||||||
|
caller kwargs last. `run_qtractor()` uses PyQt6's `exec()` API.
|
||||||
|
Existing comments and exception logging are preserved.
|
||||||
|
|
||||||
|
> `git diff HEAD -- tests/ui/test_qtractor.py`
|
||||||
|
|
||||||
|
The existing repeated-session regression now seeds stale global
|
||||||
|
registry and runtime overrides before each startup. Its real guest
|
||||||
|
asserts the requested address is bound and the caller's runtime
|
||||||
|
overrides are active before publishing readiness.
|
||||||
|
|
||||||
|
A parametrized child-process regression exercises the real blocking
|
||||||
|
adapter with normal guest completion and a sentinel exception. It
|
||||||
|
checks exception identity, window cleanup, and actor teardown. Each
|
||||||
|
child uses offscreen Qt and test-owned config and registry addresses.
|
||||||
|
No public provider, paper mode, or chart readiness work is included.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The existing worktree-local interpreter resolves Piker from this
|
||||||
|
worktree: Python 3.13.14, pytest-qt 4.5.0. The authorized Nix wrapper
|
||||||
|
ran its normal uv check; no environment provisioning was requested.
|
||||||
|
|
||||||
|
```xsh
|
||||||
|
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_qtractor.py
|
||||||
|
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_harness.py tests/ui/test_qtractor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The focused run passed 3 tests in 2.44s. The combined targeted run
|
||||||
|
passed 5 tests in 2.36s. Each reported two existing Tractor
|
||||||
|
`Registrar.uid` deprecation warnings. `git diff --check` passed.
|
||||||
|
|
||||||
|
Live Kraken charts, the full suite, paper mode, and readiness changes
|
||||||
|
were not run or implemented. The deployed harness still contains a
|
||||||
|
stale SIGINT warning for `MainWindow`; current source uses structured
|
||||||
|
shutdown. The reference was left untouched.
|
||||||
|
|
||||||
|
## Continuation: 2026-09-08T21:56:56Z
|
||||||
|
|
||||||
|
Model: openai/gpt-6-astra. The original generation above is preserved.
|
||||||
|
|
||||||
|
> `git diff HEAD -- piker/ui/_app.py`
|
||||||
|
|
||||||
|
`_async_main()` accepts `exec_mode: str = 'live'`, rejects values other
|
||||||
|
than 'live' and 'paper' before touching the widget, and stores the
|
||||||
|
selection on `GodWidget`. `load_provider_search()` publishes Trio task
|
||||||
|
startup after entering provider search registration and symcache.
|
||||||
|
The application awaits `nursery.start()` before publishing readiness.
|
||||||
|
|
||||||
|
> `git diff HEAD -- piker/ui/_widget.py`
|
||||||
|
> `git diff HEAD -- piker/ui/_display.py`
|
||||||
|
> `git diff HEAD -- piker/ui/order_mode.py`
|
||||||
|
|
||||||
|
`GodWidget.load_symbols()` forwards its session execution mode to
|
||||||
|
`display_symbol_data()`, which passes it to `open_order_mode()` and
|
||||||
|
then `open_ems(mode=exec_mode)`. Defaults remain live, matching the
|
||||||
|
existing CLI call. Keeping selection on the widget also covers later
|
||||||
|
search-driven symbol loads. No EMS, broker, or CLI implementation was
|
||||||
|
changed. Existing EMS code checks paper before acquiring brokerd;
|
||||||
|
paper relay cache keys are already distinct from live keys.
|
||||||
|
|
||||||
|
> `git diff HEAD -- tests/ui/test_chart_startup.py`
|
||||||
|
|
||||||
|
Added narrow direct tests for invalid-mode rejection before widget
|
||||||
|
access, existing CLI argument binding, explicit paper binding, and
|
||||||
|
entrypoint defaults. There are no interlayer mocks. These assertions
|
||||||
|
do not prove full chart forwarding or provider readiness at runtime.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
The authorized Nix wrapper and existing py313 interpreter resolve
|
||||||
|
Piker inside this worktree, with Python 3.13.14 and pytest-qt 4.5.0.
|
||||||
|
VIRTUAL_ENV is the worktree's py313 directory. Nix ran its normal uv
|
||||||
|
check; no separate environment provisioning was performed.
|
||||||
|
|
||||||
|
```xsh
|
||||||
|
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_chart_startup.py tests/ui/test_harness.py tests/ui/test_qtractor.py
|
||||||
|
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/test_gap_overlays.py
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
First run: 9 passed in 2.67s, two existing `Registrar.uid` deprecation
|
||||||
|
warnings. Second run: 9 passed in 2.00s, one upstream `trio.Event`
|
||||||
|
boolean-use deprecation warning. Diff whitespace check passed.
|
||||||
|
|
||||||
|
No live provider, compositor, or full-suite execution was performed.
|
||||||
|
Full chart readiness and zero-brokerd behavior need a separately
|
||||||
|
authorized live-feed test using explicit paper mode, isolated registry
|
||||||
|
and config, real search registration assertions, symbol switching,
|
||||||
|
and structured close/restart checks. Paper mode still accesses market
|
||||||
|
data and may write paper accounting state. It is not a process-wide
|
||||||
|
ban on other clients' live execution.
|
||||||
|
|
||||||
|
## Live Qualification Continuation: 2026-09-08
|
||||||
|
|
||||||
|
Active model: gpt-6-astra (`openai/gpt-6-astra`). Earlier content
|
||||||
|
above is historical and is preserved. The human now explicitly
|
||||||
|
authorizes public Kraken market data, headless Qt, and paper EMS in
|
||||||
|
this same worktree. No credentials, real orders, ownership takeover,
|
||||||
|
staging, commits, ai.skillz edits, or unrelated state changes are
|
||||||
|
authorized. The human requires an opt-in production chart test,
|
||||||
|
isolated registry/config, bounded guest teardown before widget
|
||||||
|
destruction, original errors, and stopping rather than broad repairs.
|
||||||
|
|
||||||
|
> `git diff HEAD -- tests/conftest.py`
|
||||||
|
> `git diff --no-index -- /dev/null tests/ui/test_live_chart.py`
|
||||||
|
> `git diff HEAD -- piker/brokers/kraken/api.py`
|
||||||
|
|
||||||
|
Added `--live-provider=kraken` and a production GodWidget/_async_main
|
||||||
|
journey through start_qtractor. The test supplies explicit paper mode,
|
||||||
|
the Tractor fixture's unique registry, owned root config paths, an
|
||||||
|
empty brokers.toml, and inherited piker_test_dir. Existing process XDG
|
||||||
|
isolation protects Qt settings and is inherited by children. There
|
||||||
|
are no interlayer mocks, submitted orders, or symbol interactions.
|
||||||
|
|
||||||
|
Qt polling races application readiness against guest completion.
|
||||||
|
Finally requests shutdown and pumps Qt with a 60-second bound before
|
||||||
|
normal pytest-qt widget destruction. Guest errors are unwrapped;
|
||||||
|
multiple body/teardown failures are grouped. Exact registry identities
|
||||||
|
and chart SHM names are recorded as pytest properties. Detached known
|
||||||
|
startup widgets are registered with QtBot after guest completion.
|
||||||
|
The test checks paper actors/no observed brokerd, title, markets,
|
||||||
|
nonempty history/realtime arrays, and a subsequent close-price change.
|
||||||
|
|
||||||
|
The first selected run reproduced a public-client constructor typo:
|
||||||
|
`get_client()` passed `conf={}` to `Client(config, httpx_client, ...)`.
|
||||||
|
Changed only that keyword to `config={}`. The next selected run got
|
||||||
|
past this defect and consumed public Kraken history with paper EMS.
|
||||||
|
|
||||||
|
### Exact Commands And Results
|
||||||
|
|
||||||
|
The existing authorized Nix wrapper resolves Python 3.13.14, Piker in
|
||||||
|
this worktree, pytest-qt 4.5.0, and VIRTUAL_ENV pointing at py313.
|
||||||
|
Its normal uv check ran; no separate provisioning was requested.
|
||||||
|
|
||||||
|
```xsh
|
||||||
|
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_live_chart.py tests/ui/test_chart_startup.py tests/ui/test_qtractor.py tests/ui/test_harness.py
|
||||||
|
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless --live-provider=kraken -q -x --tb=short --no-header tests/ui/test_live_chart.py
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Offline command: initially 9 passed, 1 skipped in 2.66s; final rerun
|
||||||
|
9 passed, 1 skipped in 2.65s, with two Tractor deprecation warnings.
|
||||||
|
Diff whitespace check passed.
|
||||||
|
|
||||||
|
Live command was run twice with a targeted fix between runs, not as
|
||||||
|
a blanket provider retry. First: 1 failed, 1 teardown error, 4 warnings
|
||||||
|
in 2.66s. Original guest error was retained:
|
||||||
|
|
||||||
|
```text
|
||||||
|
piker/brokers/kraken/api.py:764 in get_client
|
||||||
|
Client(conf={}, httpx_client=trio_client)
|
||||||
|
TypeError: Client.__init__() got an unexpected keyword argument
|
||||||
|
'conf'. Did you mean 'config'?
|
||||||
|
```
|
||||||
|
|
||||||
|
Second: 1 failed, 1 teardown error, 13 warnings in 12.44s.
|
||||||
|
Readiness and observed paper actor/config checks passed, then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assert set(godw.rt_linked.mode.trackers) == {'paper'}
|
||||||
|
AttributeError: 'LinkedSplits' object has no attribute 'mode'.
|
||||||
|
Did you mean: 'move'?
|
||||||
|
```
|
||||||
|
|
||||||
|
`open_order_mode()` sets its started event at order_mode.py:1024,
|
||||||
|
before `_display.py:1693` assigns rt_linked.mode after entering chart
|
||||||
|
input handlers. `_async_main()` waits for the earlier event before
|
||||||
|
publishing session readiness. Minimal proposed follow-up: publish a
|
||||||
|
display-complete readiness event after these scopes and assignment,
|
||||||
|
rather than weakening this assertion or adding a timing sleep.
|
||||||
|
|
||||||
|
Qt also reported twice, without a Python item-identifying traceback:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NotImplementedError: QGraphicsObject.paint() is abstract and must
|
||||||
|
be overridden
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI isolation fixture reported remaining QFrame, QLabel, QMenu,
|
||||||
|
and ViewBoxMenu objects. Rendering and widget-ownership fixes were
|
||||||
|
not attempted. Identify the offending graphics item and give detached
|
||||||
|
chart resources explicit ownership in a separate bounded follow-up.
|
||||||
|
|
||||||
|
Guest completion was observed before widgets were destroyed, but it
|
||||||
|
did NOT prove clean actor teardown. Tractor logs show its own forced
|
||||||
|
SIGKILL escalation for datad, samplerd, emsd and both FSP actors.
|
||||||
|
A non-mutating `ps` check found the exact paper child still alive:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PID 752893, PPID 1, paperboi.kraken
|
||||||
|
UID e8317c39-ebbb-4526-85f0-3440a2e6e39c
|
||||||
|
parent_addr ('127.0.0.1', 34915)
|
||||||
|
```
|
||||||
|
|
||||||
|
Checked known PIDs 738066, 738721, 749265, 749778, 750704, 751382,
|
||||||
|
751383, 752409 and 752893; only 752893 remained. No agent-issued
|
||||||
|
signals, manual SHM unlinking, broad cleanup, or subsequent live runs
|
||||||
|
were performed. The runtime's descendant teardown cause is not yet
|
||||||
|
established; it must be diagnosed before claiming clean shutdown.
|
||||||
|
|
||||||
|
After that failure, the test was tightened to collect chart SHM during
|
||||||
|
startup polling and check exact SHM/endpoints even after a body error.
|
||||||
|
The final version was checked offline only, since the paper survivor
|
||||||
|
remains. The reached run did not execute market/title/array/live-price
|
||||||
|
assertions or the original success-only SHM/endpoint checks. Those
|
||||||
|
are implemented expectations, not passing qualification evidence.
|
||||||
|
|
||||||
|
Further gaps: polling can miss transient actors; chart tokens omit
|
||||||
|
unpublished and non-chart child SHM; closed endpoints do not prove
|
||||||
|
PID exit; hard guest-timeout safety before widget destruction is not
|
||||||
|
qualified. No full suite, compositor, symbol switching, order journey,
|
||||||
|
or test-exclude changes were made. Existing prerequisite changes and
|
||||||
|
unrelated .gitignore/.ai state were preserved.
|
||||||
|
|
@ -762,7 +762,7 @@ async def get_client() -> Client:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
client = Client(
|
client = Client(
|
||||||
conf={},
|
config={},
|
||||||
httpx_client=trio_client,
|
httpx_client=trio_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,17 +38,26 @@ from ..log import (
|
||||||
get_console_log,
|
get_console_log,
|
||||||
)
|
)
|
||||||
from ..service import maybe_spawn_datad
|
from ..service import maybe_spawn_datad
|
||||||
from ._exec import run_qtractor
|
from ._exec import (
|
||||||
|
QtractorSession,
|
||||||
|
run_qtractor,
|
||||||
|
)
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def load_provider_search(
|
async def load_provider_search(
|
||||||
brokermod: str,
|
brokermod: ModuleType,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
) -> None:
|
*,
|
||||||
|
task_status: trio.TaskStatus[None] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Publish startup after provider search and symcache are installed.
|
||||||
|
|
||||||
|
'''
|
||||||
name = brokermod.name
|
name = brokermod.name
|
||||||
log.info(f'loading datad for {name}..')
|
log.info(f'loading datad for {name}..')
|
||||||
|
|
||||||
|
|
@ -70,6 +79,8 @@ async def load_provider_search(
|
||||||
f'BACKEND DOES NOT (yet) support symcaching: `{brokermod.name}`'
|
f'BACKEND DOES NOT (yet) support symcaching: `{brokermod.name}`'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
# keep search engine stream up until cancelled
|
# keep search engine stream up until cancelled
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
@ -82,14 +93,23 @@ async def _async_main(
|
||||||
syms: list[str],
|
syms: list[str],
|
||||||
brokers: dict[str, ModuleType],
|
brokers: dict[str, ModuleType],
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
exec_mode: str = 'live',
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
'''
|
||||||
Main Qt-trio routine invoked by the Qt loop with the widgets ``dict``.
|
Main Qt-trio routine invoked by the Qt loop with the widgets ``dict``.
|
||||||
|
|
||||||
Provision the "main" widget with initial symbol data and root nursery.
|
Provision the "main" widget with initial symbol data and root nursery.
|
||||||
|
|
||||||
"""
|
`exec_mode` selects 'live' or 'paper' EMS for this chart session,
|
||||||
|
including subsequent symbol loads through `GodWidget`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# Reject typos before chart tasks can request a live EMS dialog.
|
||||||
|
if exec_mode not in ('live', 'paper'):
|
||||||
|
raise ValueError(f'Invalid EMS execution mode: {exec_mode!r}')
|
||||||
|
main_widget.exec_mode = exec_mode
|
||||||
|
|
||||||
# enable chart's console logging
|
# enable chart's console logging
|
||||||
if loglevel:
|
if loglevel:
|
||||||
get_console_log(
|
get_console_log(
|
||||||
|
|
@ -165,7 +185,7 @@ async def _async_main(
|
||||||
# load other providers into search **after**
|
# load other providers into search **after**
|
||||||
# the chart's select cache
|
# the chart's select cache
|
||||||
for brokername, mod in needed_brokermods.items():
|
for brokername, mod in needed_brokermods.items():
|
||||||
tn.start_soon(
|
await tn.start(
|
||||||
load_provider_search,
|
load_provider_search,
|
||||||
mod,
|
mod,
|
||||||
loglevel,
|
loglevel,
|
||||||
|
|
@ -194,6 +214,14 @@ async def _async_main(
|
||||||
):
|
):
|
||||||
# remove startup status text
|
# remove startup status text
|
||||||
starting_done()
|
starting_done()
|
||||||
|
qtractor_session: QtractorSession|None = (
|
||||||
|
godwidget.window.qtractor_session
|
||||||
|
)
|
||||||
|
if qtractor_session is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'No Qtractor session owns the main window'
|
||||||
|
)
|
||||||
|
qtractor_session.set_ready()
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1298,6 +1298,7 @@ async def display_symbol_data(
|
||||||
fqmes: list[str],
|
fqmes: list[str],
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
order_mode_started: trio.Event,
|
order_mode_started: trio.Event,
|
||||||
|
exec_mode: str = 'live',
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -1669,7 +1670,8 @@ async def display_symbol_data(
|
||||||
godwidget,
|
godwidget,
|
||||||
order_ctl_fqme,
|
order_ctl_fqme,
|
||||||
order_mode_started,
|
order_mode_started,
|
||||||
loglevel=loglevel
|
loglevel=loglevel,
|
||||||
|
exec_mode=exec_mode,
|
||||||
) as mode,
|
) as mode,
|
||||||
|
|
||||||
# TODO: maybe have these startup sooner before
|
# TODO: maybe have these startup sooner before
|
||||||
|
|
|
||||||
|
|
@ -22,24 +22,22 @@ All global Qt runtime settings are mostly defined here.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import (
|
from typing import (
|
||||||
Callable,
|
|
||||||
Any,
|
Any,
|
||||||
Type,
|
Awaitable,
|
||||||
|
Callable,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
import platform
|
import platform
|
||||||
import traceback
|
|
||||||
|
|
||||||
import qdarkstyle
|
import qdarkstyle
|
||||||
from qdarkstyle import DarkPalette
|
from qdarkstyle import DarkPalette
|
||||||
# import qdarkgraystyle # TODO: play with it
|
# import qdarkgraystyle # TODO: play with it
|
||||||
import trio
|
import trio
|
||||||
from outcome import Error
|
from outcome import Outcome
|
||||||
|
|
||||||
# Qt version-agnostic
|
# Qt version-agnostic
|
||||||
from .qt import (
|
from .qt import (
|
||||||
QWidget,
|
QWidget,
|
||||||
QMainWindow,
|
|
||||||
QApplication,
|
QApplication,
|
||||||
QtCore,
|
QtCore,
|
||||||
pyqtRemoveInputHook,
|
pyqtRemoveInputHook,
|
||||||
|
|
@ -56,6 +54,7 @@ from . import _style
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from ._window import MainWindow
|
||||||
from ._widget import GodWidget
|
from ._widget import GodWidget
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ _do_overrides()
|
||||||
# XXX: pretty sure none of this shit works on linux as per:
|
# XXX: pretty sure none of this shit works on linux as per:
|
||||||
# https://bugreports.qt.io/browse/QTBUG-53022
|
# https://bugreports.qt.io/browse/QTBUG-53022
|
||||||
# it seems to work on windows.. no idea wtf is up.
|
# it seems to work on windows.. no idea wtf is up.
|
||||||
is_windows = False
|
is_windows: bool = False
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
is_windows = True
|
is_windows = True
|
||||||
|
|
||||||
|
|
@ -83,24 +82,131 @@ if platform.system() == "Windows":
|
||||||
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||||
|
|
||||||
|
|
||||||
def run_qtractor(
|
class QtractorSession(QtCore.QObject):
|
||||||
func: Callable,
|
|
||||||
args: tuple,
|
|
||||||
main_widget_type: Type[QWidget],
|
|
||||||
tractor_kwargs: dict[str, Any] = {},
|
|
||||||
window_type: QMainWindow = None,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
'''
|
||||||
Run the Qt event loop and embed `trio` via guest mode on it.
|
Own one Trio guest run embedded in a Qt application.
|
||||||
|
|
||||||
|
'''
|
||||||
|
application_ready = QtCore.pyqtSignal()
|
||||||
|
completed = QtCore.pyqtSignal(object)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app: QApplication,
|
||||||
|
window: MainWindow,
|
||||||
|
main_widget: QWidget,
|
||||||
|
owns_app_exec: bool,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Initialize state owned by one Qt-hosted Trio guest run.
|
||||||
|
|
||||||
|
'''
|
||||||
|
super().__init__()
|
||||||
|
self.app = app
|
||||||
|
self.window = window
|
||||||
|
self.main_widget = main_widget
|
||||||
|
self.owns_app_exec = owns_app_exec
|
||||||
|
self.trio_token: trio.lowlevel.TrioToken|None = None
|
||||||
|
self.cancel_scope: trio.CancelScope|None = None
|
||||||
|
self.outcome: Outcome[Any]|None = None
|
||||||
|
self.is_ready: bool = False
|
||||||
|
self._shutdown_requested: bool = False
|
||||||
|
self._shutdown_scheduled: bool = False
|
||||||
|
|
||||||
|
def set_ready(self) -> None:
|
||||||
|
'''
|
||||||
|
Publish that the application entered all startup scopes.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self.is_ready:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.is_ready = True
|
||||||
|
self.application_ready.emit()
|
||||||
|
|
||||||
|
def request_shutdown(self) -> None:
|
||||||
|
'''
|
||||||
|
Cancel the owned Trio guest run through its host token.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self.outcome is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._shutdown_requested = True
|
||||||
|
token: trio.lowlevel.TrioToken|None = self.trio_token
|
||||||
|
cancel_scope: trio.CancelScope|None = self.cancel_scope
|
||||||
|
if (
|
||||||
|
token is None
|
||||||
|
or
|
||||||
|
cancel_scope is None
|
||||||
|
or
|
||||||
|
self._shutdown_scheduled
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._shutdown_scheduled = True
|
||||||
|
try:
|
||||||
|
token.run_sync_soon(cancel_scope.cancel)
|
||||||
|
except trio.RunFinishedError:
|
||||||
|
log.debug(
|
||||||
|
'Trio guest run already finished before shutdown'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _guest_started(
|
||||||
|
self,
|
||||||
|
token: trio.lowlevel.TrioToken,
|
||||||
|
cancel_scope: trio.CancelScope,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
self.trio_token = token
|
||||||
|
self.cancel_scope = cancel_scope
|
||||||
|
if self._shutdown_requested:
|
||||||
|
cancel_scope.cancel()
|
||||||
|
|
||||||
|
def _guest_completed(
|
||||||
|
self,
|
||||||
|
final_outcome: Outcome[Any],
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
self.outcome = final_outcome
|
||||||
|
self.completed.emit(final_outcome)
|
||||||
|
|
||||||
|
|
||||||
|
def start_qtractor(
|
||||||
|
func: Callable[..., Awaitable[Any]],
|
||||||
|
args: tuple[Any, ...],
|
||||||
|
main_widget_type: type[QWidget],
|
||||||
|
tractor_kwargs: dict[str, Any]|None = None,
|
||||||
|
window_type: type[MainWindow]|None = None,
|
||||||
|
app: QApplication|None = None,
|
||||||
|
owns_app_exec: bool = False,
|
||||||
|
|
||||||
|
) -> QtractorSession:
|
||||||
|
'''
|
||||||
|
Start a Trio guest run without entering Qt's event loop.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# avoids annoying message when entering debugger from qt loop
|
# avoids annoying message when entering debugger from qt loop
|
||||||
pyqtRemoveInputHook()
|
pyqtRemoveInputHook()
|
||||||
|
|
||||||
app = QApplication.instance()
|
running_app: QApplication|None = QApplication.instance()
|
||||||
if app is None:
|
if app is None:
|
||||||
app = QApplication([])
|
app = running_app or QApplication([])
|
||||||
|
elif (
|
||||||
|
running_app is not None
|
||||||
|
and
|
||||||
|
running_app is not app
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
'The requested QApplication is not the active instance'
|
||||||
|
)
|
||||||
|
|
||||||
|
prior_quit_on_last_window: bool = (
|
||||||
|
app.quitOnLastWindowClosed()
|
||||||
|
)
|
||||||
|
prior_cursor_flash_time: int = app.cursorFlashTime()
|
||||||
|
prior_stylesheet: str = app.styleSheet()
|
||||||
|
|
||||||
# TODO: we might not need this if it's desired
|
# TODO: we might not need this if it's desired
|
||||||
# to cancel the tractor machinery on Qt loop
|
# to cancel the tractor machinery on Qt loop
|
||||||
|
|
@ -118,39 +224,58 @@ def run_qtractor(
|
||||||
|
|
||||||
# source gist and credit to njs:
|
# source gist and credit to njs:
|
||||||
# https://gist.github.com/njsmith/d996e80b700a339e0623f97f48bcf0cb
|
# https://gist.github.com/njsmith/d996e80b700a339e0623f97f48bcf0cb
|
||||||
REENTER_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
|
REENTER_EVENT: QtCore.QEvent.Type = QtCore.QEvent.Type(
|
||||||
|
QtCore.QEvent.registerEventType()
|
||||||
|
)
|
||||||
|
|
||||||
class ReenterEvent(QtCore.QEvent):
|
class ReenterEvent(QtCore.QEvent):
|
||||||
pass
|
'''
|
||||||
|
Carry one Trio guest callback through Qt's event queue.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fn: Callable[[], None],
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Initialize the event with its scheduled callback.
|
||||||
|
|
||||||
|
'''
|
||||||
|
super().__init__(REENTER_EVENT)
|
||||||
|
self.fn: Callable[[], None] = fn
|
||||||
|
|
||||||
class Reenter(QtCore.QObject):
|
class Reenter(QtCore.QObject):
|
||||||
def event(self, event):
|
'''
|
||||||
|
Dispatch Trio re-entry events received from Qt.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def event(self, event: QtCore.QEvent) -> bool:
|
||||||
|
'''
|
||||||
|
Run a scheduled callback or defer another event to Qt.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if not isinstance(event, ReenterEvent):
|
||||||
|
return super().event(event)
|
||||||
|
|
||||||
event.fn()
|
event.fn()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
reenter = Reenter()
|
reenter: Reenter = Reenter()
|
||||||
|
|
||||||
def run_sync_soon_threadsafe(fn):
|
def run_sync_soon_threadsafe(
|
||||||
event = ReenterEvent(REENTER_EVENT)
|
fn: Callable[[], None],
|
||||||
event.fn = fn
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Post one Trio guest callback onto Qt's event queue.
|
||||||
|
|
||||||
|
'''
|
||||||
|
event: ReenterEvent = ReenterEvent(fn)
|
||||||
app.postEvent(reenter, event)
|
app.postEvent(reenter, event)
|
||||||
|
|
||||||
def done_callback(outcome):
|
|
||||||
|
|
||||||
if isinstance(outcome, Error):
|
|
||||||
exc = outcome.error
|
|
||||||
|
|
||||||
if isinstance(outcome.error, KeyboardInterrupt):
|
|
||||||
# make it kinda look like ``trio``
|
|
||||||
print("Terminated!")
|
|
||||||
|
|
||||||
else:
|
|
||||||
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
||||||
|
|
||||||
app.quit()
|
|
||||||
|
|
||||||
# load dark theme
|
# load dark theme
|
||||||
stylesheet = qdarkstyle.load_stylesheet(
|
stylesheet: str = qdarkstyle.load_stylesheet(
|
||||||
qt_api='pyqt6',
|
qt_api='pyqt6',
|
||||||
palette=DarkPalette,
|
palette=DarkPalette,
|
||||||
)
|
)
|
||||||
|
|
@ -162,7 +287,7 @@ def run_qtractor(
|
||||||
if window_type is None:
|
if window_type is None:
|
||||||
window_type = _window.MainWindow
|
window_type = _window.MainWindow
|
||||||
|
|
||||||
window = window_type()
|
window: MainWindow = window_type()
|
||||||
|
|
||||||
# set global app's main window singleton
|
# set global app's main window singleton
|
||||||
_window._qt_win = window
|
_window._qt_win = window
|
||||||
|
|
@ -177,18 +302,65 @@ def run_qtractor(
|
||||||
instance: GodWidget = main_widget_type()
|
instance: GodWidget = main_widget_type()
|
||||||
instance.window = window
|
instance.window = window
|
||||||
|
|
||||||
# override tractor's defaults
|
session = QtractorSession(
|
||||||
tractor_kwargs.update(
|
app=app,
|
||||||
get_runtime_vars()
|
window=window,
|
||||||
|
main_widget=instance,
|
||||||
|
owns_app_exec=owns_app_exec,
|
||||||
)
|
)
|
||||||
|
window.qtractor_session = session
|
||||||
|
window.set_shutdown_callback(session.request_shutdown)
|
||||||
|
|
||||||
|
# override tractor's defaults
|
||||||
|
tractor_kwargs = {
|
||||||
|
**get_runtime_vars(),
|
||||||
|
**(tractor_kwargs or {}),
|
||||||
|
}
|
||||||
|
|
||||||
# define tractor entrypoint
|
# define tractor entrypoint
|
||||||
async def main():
|
async def main() -> None:
|
||||||
|
'''
|
||||||
|
Run the application under its owned Trio cancel scope.
|
||||||
|
|
||||||
async with maybe_open_pikerd(
|
'''
|
||||||
**tractor_kwargs,
|
cancel_scope: trio.CancelScope
|
||||||
|
with trio.CancelScope() as cancel_scope:
|
||||||
|
session._guest_started(
|
||||||
|
trio.lowlevel.current_trio_token(),
|
||||||
|
cancel_scope,
|
||||||
|
)
|
||||||
|
async with maybe_open_pikerd(
|
||||||
|
**tractor_kwargs,
|
||||||
|
):
|
||||||
|
await func(*((instance,) + args))
|
||||||
|
|
||||||
|
def done_callback(final_outcome: Outcome[Any]) -> None:
|
||||||
|
'''
|
||||||
|
Restore Qt host state and publish guest completion.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
app.focusChanged.disconnect(window.on_focus_change)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
RuntimeError,
|
||||||
):
|
):
|
||||||
await func(*((instance,) + args))
|
log.exception(
|
||||||
|
'Failed to disconnect '
|
||||||
|
'`MainWindow.on_focus_change()`'
|
||||||
|
)
|
||||||
|
window.uninstall_global_zoom_filter()
|
||||||
|
window.set_shutdown_callback(None)
|
||||||
|
window.qtractor_session = None
|
||||||
|
if _window._qt_win is window:
|
||||||
|
_window._qt_win = None
|
||||||
|
if not owns_app_exec:
|
||||||
|
app.setQuitOnLastWindowClosed(
|
||||||
|
prior_quit_on_last_window
|
||||||
|
)
|
||||||
|
app.setCursorFlashTime(prior_cursor_flash_time)
|
||||||
|
app.setStyleSheet(prior_stylesheet)
|
||||||
|
session._guest_completed(final_outcome)
|
||||||
|
|
||||||
# guest mode entry
|
# guest mode entry
|
||||||
trio.lowlevel.start_guest_run(
|
trio.lowlevel.start_guest_run(
|
||||||
|
|
@ -208,4 +380,46 @@ def run_qtractor(
|
||||||
|
|
||||||
# actually render to screen
|
# actually render to screen
|
||||||
window.show()
|
window.show()
|
||||||
app.exec_()
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def run_qtractor(
|
||||||
|
func: Callable[..., Awaitable[Any]],
|
||||||
|
args: tuple[Any, ...],
|
||||||
|
main_widget_type: type[QWidget],
|
||||||
|
tractor_kwargs: dict[str, Any]|None = None,
|
||||||
|
window_type: type[MainWindow]|None = None,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Run Qt and unwrap the embedded Trio guest run's result.
|
||||||
|
|
||||||
|
'''
|
||||||
|
session: QtractorSession = start_qtractor(
|
||||||
|
func=func,
|
||||||
|
args=args,
|
||||||
|
main_widget_type=main_widget_type,
|
||||||
|
tractor_kwargs=tractor_kwargs,
|
||||||
|
window_type=window_type,
|
||||||
|
owns_app_exec=True,
|
||||||
|
)
|
||||||
|
session.completed.connect(session.app.quit)
|
||||||
|
try:
|
||||||
|
session.app.exec()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session.completed.disconnect(session.app.quit)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
RuntimeError,
|
||||||
|
):
|
||||||
|
log.exception(
|
||||||
|
'Failed to disconnect `QApplication.quit()`'
|
||||||
|
)
|
||||||
|
|
||||||
|
final_outcome: Outcome[Any]|None = session.outcome
|
||||||
|
if final_outcome is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Qt exited before the Trio guest run completed'
|
||||||
|
)
|
||||||
|
final_outcome.unwrap()
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ class GodWidget(QWidget):
|
||||||
'''
|
'''
|
||||||
search: SearchWidget
|
search: SearchWidget
|
||||||
mode_name: str = 'god'
|
mode_name: str = 'god'
|
||||||
|
# `_async_main()` selects EMS execution before any symbol loads.
|
||||||
|
exec_mode: str = 'live'
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
||||||
|
|
@ -207,6 +209,7 @@ class GodWidget(QWidget):
|
||||||
fqmes,
|
fqmes,
|
||||||
loglevel,
|
loglevel,
|
||||||
order_mode_started,
|
order_mode_started,
|
||||||
|
self.exec_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# self.vbox.addWidget(hist_charts)
|
# self.vbox.addWidget(hist_charts)
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,10 @@ Qt main window singletons and stuff.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import time
|
import time
|
||||||
from typing import (
|
from typing import (
|
||||||
Callable,
|
Callable,
|
||||||
|
TYPE_CHECKING,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -51,6 +50,10 @@ from ._style import (
|
||||||
from ._widget import GodWidget
|
from ._widget import GodWidget
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ._exec import QtractorSession
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -291,15 +294,45 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# event filter for global zoom shortcuts
|
# event filter for global zoom shortcuts
|
||||||
self._zoom_filter: GlobalZoomEventFilter | None = None
|
self._zoom_filter: GlobalZoomEventFilter | None = None
|
||||||
|
self._shutdown_callback: Callable[[], None]|None = None
|
||||||
|
self.qtractor_session: QtractorSession|None = None
|
||||||
|
|
||||||
def install_global_zoom_filter(self) -> None:
|
def install_global_zoom_filter(self) -> None:
|
||||||
'''Install application-level event filter for global UI zoom shortcuts.'''
|
'''Install application-level event filter for global UI zoom shortcuts.'''
|
||||||
if self._zoom_filter is None:
|
if self._zoom_filter is None:
|
||||||
self._zoom_filter = GlobalZoomEventFilter(self)
|
self._zoom_filter = GlobalZoomEventFilter(self)
|
||||||
app = QApplication.instance()
|
app: QApplication|None = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Cannot install zoom filter without a QApplication'
|
||||||
|
)
|
||||||
app.installEventFilter(self._zoom_filter)
|
app.installEventFilter(self._zoom_filter)
|
||||||
log.info('Installed global zoom shortcuts: Ctrl+Shift+Plus/Minus/0')
|
log.info('Installed global zoom shortcuts: Ctrl+Shift+Plus/Minus/0')
|
||||||
|
|
||||||
|
def uninstall_global_zoom_filter(self) -> None:
|
||||||
|
'''
|
||||||
|
Remove this window's application-level zoom filter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self._zoom_filter is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
app: QApplication|None = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.removeEventFilter(self._zoom_filter)
|
||||||
|
self._zoom_filter = None
|
||||||
|
|
||||||
|
def set_shutdown_callback(
|
||||||
|
self,
|
||||||
|
callback: Callable[[], None]|None,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Set the structured runtime-shutdown request callback.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self._shutdown_callback = callback
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode_label(self) -> QLabel:
|
def mode_label(self) -> QLabel:
|
||||||
|
|
||||||
|
|
@ -341,11 +374,10 @@ class MainWindow(QMainWindow):
|
||||||
settings.setValue('windowGeometry', self.saveGeometry())
|
settings.setValue('windowGeometry', self.saveGeometry())
|
||||||
log.debug('Saved window geometry for next session')
|
log.debug('Saved window geometry for next session')
|
||||||
|
|
||||||
# raising KBI seems to get intercepted by by Qt so just use the system.
|
if self._shutdown_callback is not None:
|
||||||
os.kill(
|
self._shutdown_callback()
|
||||||
os.getpid(),
|
|
||||||
signal.SIGINT,
|
super().closeEvent(event)
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_bar(self) -> QStatusBar:
|
def status_bar(self) -> QStatusBar:
|
||||||
|
|
|
||||||
|
|
@ -785,7 +785,8 @@ async def open_order_mode(
|
||||||
godw: GodWidget,
|
godw: GodWidget,
|
||||||
fqme: str,
|
fqme: str,
|
||||||
started: trio.Event,
|
started: trio.Event,
|
||||||
loglevel: str = 'info'
|
loglevel: str = 'info',
|
||||||
|
exec_mode: str = 'live',
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -816,6 +817,7 @@ async def open_order_mode(
|
||||||
async with (
|
async with (
|
||||||
open_ems(
|
open_ems(
|
||||||
fqme,
|
fqme,
|
||||||
|
mode=exec_mode,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
) as (
|
) as (
|
||||||
client,
|
client,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@ def qapp_args(
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
|
parser.addoption(
|
||||||
|
'--live-provider',
|
||||||
|
choices=('kraken',),
|
||||||
|
default=None,
|
||||||
|
help='Opt into public-provider chart qualification',
|
||||||
|
)
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
'--headless',
|
'--headless',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
from inspect import (
|
||||||
|
BoundArguments,
|
||||||
|
signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from piker.ui._app import _async_main
|
||||||
|
from piker.ui._display import display_symbol_data
|
||||||
|
from piker.ui._widget import GodWidget
|
||||||
|
from piker.ui.order_mode import open_order_mode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('exec_mode', ['', 'Paper', 'dark'])
|
||||||
|
def test_chart_rejects_invalid_ems_mode(exec_mode: str) -> None:
|
||||||
|
'''
|
||||||
|
Reject misspelled execution modes before touching chart startup.
|
||||||
|
|
||||||
|
EMS treats non-paper mode as live when a backend supports trades.
|
||||||
|
A chart caller must not accidentally request that path with a
|
||||||
|
typo or the order-trigger mode 'dark'. Invoke the actual startup
|
||||||
|
coroutine with no widget or backend: ValueError, rather than a
|
||||||
|
widget access or runtime error, proves rejection precedes chart
|
||||||
|
initialization and any tasks that could acquire brokerd.
|
||||||
|
|
||||||
|
This is a direct input-boundary test, not an EMS integration test.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(ValueError, match='Invalid EMS execution mode'):
|
||||||
|
trio.run(
|
||||||
|
_async_main,
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
'warning',
|
||||||
|
exec_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_ems_mode_call_contract() -> None:
|
||||||
|
'''
|
||||||
|
Preserve the shipped CLI argument shape while enabling paper EMS.
|
||||||
|
|
||||||
|
`_main()` passes only widget, symbols, brokers and loglevel to
|
||||||
|
`_async_main()`. Bind that concrete call shape and apply defaults
|
||||||
|
to verify it remains live; bind an explicit paper argument to
|
||||||
|
verify integration callers need no CLI or runtime adapter changes.
|
||||||
|
Check the widget and lower entrypoint defaults as well, since
|
||||||
|
omitted arguments must not silently change shipped execution.
|
||||||
|
|
||||||
|
These are direct API-contract assertions. They do not claim to
|
||||||
|
exercise forwarding through a running chart or broker selection.
|
||||||
|
|
||||||
|
'''
|
||||||
|
cli_call: BoundArguments = signature(_async_main).bind(
|
||||||
|
None,
|
||||||
|
['xbtusd.kraken'],
|
||||||
|
{},
|
||||||
|
'warning',
|
||||||
|
)
|
||||||
|
cli_call.apply_defaults()
|
||||||
|
assert cli_call.arguments['exec_mode'] == 'live'
|
||||||
|
|
||||||
|
paper_call: BoundArguments = signature(_async_main).bind(
|
||||||
|
*cli_call.args[:-1],
|
||||||
|
exec_mode='paper',
|
||||||
|
)
|
||||||
|
assert paper_call.arguments['exec_mode'] == 'paper'
|
||||||
|
assert GodWidget.exec_mode == 'live'
|
||||||
|
assert signature(display_symbol_data).parameters[
|
||||||
|
'exec_mode'
|
||||||
|
].default == 'live'
|
||||||
|
assert signature(open_order_mode).parameters[
|
||||||
|
'exec_mode'
|
||||||
|
].default == 'live'
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
from time import monotonic
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
import pytest
|
||||||
|
from pytestqt.qtbot import QtBot
|
||||||
|
import tractor
|
||||||
|
from tractor.ipc import _shm
|
||||||
|
|
||||||
|
from piker import config
|
||||||
|
from piker.brokers import get_brokermod
|
||||||
|
from piker.ui._app import _async_main
|
||||||
|
from piker.ui._exec import start_qtractor
|
||||||
|
from piker.ui._widget import GodWidget
|
||||||
|
|
||||||
|
|
||||||
|
def test_kraken_chart_boots_and_closes(
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
isolated_ui_state: SimpleNamespace,
|
||||||
|
qapp: QApplication,
|
||||||
|
qtbot: QtBot,
|
||||||
|
reg_addr: tuple[str, int],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
record_property: Callable[[str, object], None],
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Qualify production chart boot against public Kraken, not mocks.
|
||||||
|
|
||||||
|
A blocking Qt host and hidden Trio errors previously prevented
|
||||||
|
testing the composed chart. Explicit selection starts GodWidget
|
||||||
|
and `_async_main` with paper EMS, an empty owned broker config,
|
||||||
|
and the test's unique registry. No orders or input are submitted.
|
||||||
|
Readiness races guest completion so a provider/startup exception
|
||||||
|
is unwrapped rather than replaced by a readiness timeout.
|
||||||
|
|
||||||
|
Nonempty historical/realtime visualizations and a later price
|
||||||
|
change prove actual feed consumption. Registry snapshots check
|
||||||
|
paperboi and absence of brokerd; exact chart SHM names and actor
|
||||||
|
endpoints are retained for non-mutating teardown checks. These
|
||||||
|
snapshots cannot rule out short-lived actors between polls or
|
||||||
|
account for SHM never published to a chart before early failure.
|
||||||
|
|
||||||
|
Every exit requests cancellation and pumps Qt to guest completion
|
||||||
|
before pytest-qt may destroy widgets. Cleanup and guest failures
|
||||||
|
are retained together rather than hiding either original error.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if request.config.getoption('--live-provider') != 'kraken':
|
||||||
|
pytest.skip('requires --live-provider=kraken')
|
||||||
|
|
||||||
|
fqme: str = 'xbtusd.spot.kraken'
|
||||||
|
confdir: Path = isolated_ui_state.test_config_dir
|
||||||
|
# A missing brokers.toml loads templates with example accounts.
|
||||||
|
(confdir / 'brokers.toml').write_text('', encoding='utf-8')
|
||||||
|
for name in ('_config_dir', '_click_config_dir'):
|
||||||
|
monkeypatch.setattr(config, name, confdir)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config,
|
||||||
|
'_watchlists_data_path',
|
||||||
|
confdir / 'watchlists.json',
|
||||||
|
)
|
||||||
|
assert config.load('brokers')[0] == {}
|
||||||
|
|
||||||
|
owned_actors: dict[tuple[str, str], list] = {}
|
||||||
|
shm_names: set[str] = set()
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
started: float = monotonic()
|
||||||
|
session = start_qtractor(
|
||||||
|
func=_async_main,
|
||||||
|
args=(
|
||||||
|
[fqme],
|
||||||
|
{'kraken': get_brokermod('kraken')},
|
||||||
|
'warning',
|
||||||
|
'paper',
|
||||||
|
),
|
||||||
|
main_widget_type=GodWidget,
|
||||||
|
tractor_kwargs={
|
||||||
|
'registry_addrs': [reg_addr],
|
||||||
|
'tractor_runtime_overrides': {
|
||||||
|
'piker_test_dir': str(confdir),
|
||||||
|
},
|
||||||
|
'loglevel': 'warning',
|
||||||
|
},
|
||||||
|
app=qapp,
|
||||||
|
)
|
||||||
|
|
||||||
|
def observe_runtime() -> None:
|
||||||
|
'''
|
||||||
|
Retain identities from this guest's local Registrar only.
|
||||||
|
|
||||||
|
'''
|
||||||
|
actor = tractor.current_actor(err_on_no_runtime=False)
|
||||||
|
if actor is not None:
|
||||||
|
assert actor.is_registrar
|
||||||
|
assert reg_addr in actor.accept_addrs
|
||||||
|
owned_actors.update(actor._registry)
|
||||||
|
assert not any(
|
||||||
|
uid[0].startswith('brokerd.')
|
||||||
|
for uid in owned_actors
|
||||||
|
)
|
||||||
|
for attr in ('hist_linked', 'rt_linked'):
|
||||||
|
linked = getattr(session.main_widget, attr, None)
|
||||||
|
chart = getattr(linked, 'chart', None)
|
||||||
|
viz = getattr(chart, 'main_viz', None)
|
||||||
|
if viz is not None:
|
||||||
|
token: dict = viz.shm.token
|
||||||
|
shm_names.update(
|
||||||
|
token[key] for key in (
|
||||||
|
'shm_name',
|
||||||
|
'shm_first_index_name',
|
||||||
|
'shm_last_index_name',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def boot_finished() -> bool:
|
||||||
|
observe_runtime()
|
||||||
|
return session.is_ready or session.outcome is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
qtbot.addWidget(session.window)
|
||||||
|
qtbot.waitUntil(boot_finished, timeout=120_000)
|
||||||
|
record_property('boot_seconds', monotonic() - started)
|
||||||
|
if session.outcome is None:
|
||||||
|
godw = session.main_widget
|
||||||
|
assert session.is_ready
|
||||||
|
assert godw.exec_mode == 'paper'
|
||||||
|
assert config.get_conf_dir() == confdir
|
||||||
|
assert tractor.runtime._state._runtime_vars[
|
||||||
|
'piker_vars'
|
||||||
|
]['piker_test_dir'] == str(confdir)
|
||||||
|
names: set[str] = {uid[0] for uid in owned_actors}
|
||||||
|
assert {
|
||||||
|
'pikerd', 'datad.kraken', 'samplerd',
|
||||||
|
'emsd', 'paperboi.kraken',
|
||||||
|
} <= names
|
||||||
|
assert set(godw.rt_linked.mode.trackers) == {'paper'}
|
||||||
|
assert not godw.rt_linked.mode.client._sent_orders
|
||||||
|
assert session.window.windowTitle().startswith(fqme)
|
||||||
|
for linked in (godw.hist_linked, godw.rt_linked):
|
||||||
|
assert linked.mkt.fqme == fqme
|
||||||
|
viz = linked.chart.main_viz
|
||||||
|
data = viz.shm.array
|
||||||
|
assert len(data) > 1
|
||||||
|
assert np.isfinite(data['close']).all()
|
||||||
|
assert (data['close'] > 0).any()
|
||||||
|
|
||||||
|
rt_shm = godw.rt_linked.chart.main_viz.shm
|
||||||
|
before = rt_shm.array[-1].copy()
|
||||||
|
|
||||||
|
def live_update_or_exit() -> bool:
|
||||||
|
observe_runtime()
|
||||||
|
if session.outcome is not None:
|
||||||
|
return True
|
||||||
|
# A sampler clock tick alone must not satisfy this.
|
||||||
|
return bool(
|
||||||
|
rt_shm.array[-1]['close'] != before['close']
|
||||||
|
)
|
||||||
|
|
||||||
|
qtbot.waitUntil(live_update_or_exit, timeout=60_000)
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
session.request_shutdown()
|
||||||
|
try:
|
||||||
|
qtbot.waitUntil(
|
||||||
|
lambda: session.outcome is not None,
|
||||||
|
timeout=60_000,
|
||||||
|
)
|
||||||
|
session.outcome.unwrap()
|
||||||
|
except BaseException as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
if session.outcome is not None:
|
||||||
|
# Early failure can leave these outside Qt parent trees.
|
||||||
|
for attr in ('search', 'hist_linked', 'rt_linked'):
|
||||||
|
widget = getattr(session.main_widget, attr, None)
|
||||||
|
if widget is not None and widget.parent() is None:
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
record_property('owned_actors', repr(owned_actors))
|
||||||
|
record_property('chart_shm_names', sorted(shm_names))
|
||||||
|
|
||||||
|
# Check exact survivors even when boot assertions already failed.
|
||||||
|
if tractor.current_actor(err_on_no_runtime=False) is not None:
|
||||||
|
errors.append(AssertionError('Guest runtime survived'))
|
||||||
|
for name in shm_names:
|
||||||
|
try:
|
||||||
|
segment = _shm.SharedMemory(name=name, create=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
segment.close()
|
||||||
|
errors.append(AssertionError(
|
||||||
|
f'Chart SHM survived guest completion: {name}'
|
||||||
|
))
|
||||||
|
endpoints: set[tuple] = {reg_addr}
|
||||||
|
for addrs in owned_actors.values():
|
||||||
|
endpoints.update(map(tuple, addrs))
|
||||||
|
for addr in endpoints:
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.settimeout(1)
|
||||||
|
if sock.connect_ex(addr) == 0:
|
||||||
|
errors.append(AssertionError(
|
||||||
|
f'Owned actor endpoint survived: {addr}'
|
||||||
|
))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
if len(errors) == 1:
|
||||||
|
raise errors[0]
|
||||||
|
raise BaseExceptionGroup('Chart and shutdown failed', errors)
|
||||||
|
assert session.is_ready, 'guest returned before readiness'
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from textwrap import dedent
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PyQt6.QtGui import QResizeEvent
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
from pytestqt.qtbot import QtBot
|
||||||
|
from pytestqt.wait_signal import SignalBlocker
|
||||||
|
import pytest
|
||||||
|
import tractor
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from piker.service import get_runtime_vars
|
||||||
|
from piker.ui import _window
|
||||||
|
from piker.ui._exec import (
|
||||||
|
QtractorSession,
|
||||||
|
start_qtractor,
|
||||||
|
)
|
||||||
|
from piker.ui._window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class QtractorTestWidget(QWidget):
|
||||||
|
'''
|
||||||
|
Supply the central-widget resize hook expected by `MainWindow`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
window: MainWindow
|
||||||
|
|
||||||
|
def on_win_resize(self, event: QResizeEvent) -> None:
|
||||||
|
'''
|
||||||
|
Accept production `MainWindow.resizeEvent()` notifications.
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
async def run_until_window_close(
|
||||||
|
main_widget: QtractorTestWidget,
|
||||||
|
exited: list[QtractorTestWidget],
|
||||||
|
reg_addr: tuple[str, int],
|
||||||
|
runtime_overrides: dict[str, Any],
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Publish readiness and retain the guest task until cancellation.
|
||||||
|
|
||||||
|
'''
|
||||||
|
session: QtractorSession|None = (
|
||||||
|
main_widget.window.qtractor_session
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'No Qtractor session owns the test window'
|
||||||
|
)
|
||||||
|
actor: tractor.Actor = tractor.current_actor()
|
||||||
|
assert reg_addr in actor.accept_addrs
|
||||||
|
assert tractor.runtime._state._runtime_vars[
|
||||||
|
'piker_vars'
|
||||||
|
] == runtime_overrides
|
||||||
|
session.set_ready()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
finally:
|
||||||
|
exited.append(main_widget)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qtractor_restarts_after_structured_window_close(
|
||||||
|
isolated_ui_state: SimpleNamespace,
|
||||||
|
qapp: QApplication,
|
||||||
|
qtbot: QtBot,
|
||||||
|
reg_addr: tuple[str, int],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Repeated Qt sessions must not signal or strand the Trio runtime.
|
||||||
|
|
||||||
|
`MainWindow.closeEvent()` previously sent process-wide SIGINT
|
||||||
|
while `run_qtractor()` hid the guest outcome behind blocking
|
||||||
|
`app.exec_()`. A caller-owned pytest-qt application therefore
|
||||||
|
could not observe a clean startup or close one session without
|
||||||
|
interrupting pytest.
|
||||||
|
|
||||||
|
Start two real Tractor root runtimes in sequence on the
|
||||||
|
test-owned registry. Each guest publishes readiness only after
|
||||||
|
entering its task, then a real window-close event requests
|
||||||
|
cancellation through `QtractorSession`. Completion, the guest
|
||||||
|
`finally` block, the empty runtime slot, and successful second
|
||||||
|
startup prove structured teardown completed without quitting the
|
||||||
|
shared Qt application.
|
||||||
|
|
||||||
|
Before each start, seed `get_runtime_vars()` with a stale
|
||||||
|
loopback registry and conflicting runtime overrides. Previously
|
||||||
|
these globals overwrote explicit caller kwargs. Assertions inside
|
||||||
|
the guest verify the actual bound address and runtime state before
|
||||||
|
readiness, rather than merely checking forwarded arguments.
|
||||||
|
|
||||||
|
'''
|
||||||
|
assert isolated_ui_state.qapp is qapp
|
||||||
|
exited: list[QtractorTestWidget] = []
|
||||||
|
qt_state: tuple[bool, int, str] = (
|
||||||
|
qapp.quitOnLastWindowClosed(),
|
||||||
|
qapp.cursorFlashTime(),
|
||||||
|
qapp.styleSheet(),
|
||||||
|
)
|
||||||
|
tractor_kwargs: dict[str, Any] = {
|
||||||
|
'registry_addrs': [reg_addr],
|
||||||
|
'tractor_runtime_overrides': {
|
||||||
|
'piker_test_dir': str(
|
||||||
|
isolated_ui_state.test_config_dir
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_: int
|
||||||
|
for _ in range(2):
|
||||||
|
monkeypatch.setitem(
|
||||||
|
get_runtime_vars(),
|
||||||
|
'registry_addrs',
|
||||||
|
[('127.0.0.1', 0)],
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
get_runtime_vars(),
|
||||||
|
'tractor_runtime_overrides',
|
||||||
|
{'stale': True},
|
||||||
|
)
|
||||||
|
session: QtractorSession = start_qtractor(
|
||||||
|
func=run_until_window_close,
|
||||||
|
args=(
|
||||||
|
exited,
|
||||||
|
reg_addr,
|
||||||
|
tractor_kwargs['tractor_runtime_overrides'],
|
||||||
|
),
|
||||||
|
main_widget_type=QtractorTestWidget,
|
||||||
|
tractor_kwargs=tractor_kwargs,
|
||||||
|
app=qapp,
|
||||||
|
)
|
||||||
|
qtbot.addWidget(session.window)
|
||||||
|
|
||||||
|
assert session.app is qapp
|
||||||
|
assert not session.owns_app_exec
|
||||||
|
assert session.window.godwidget is session.main_widget
|
||||||
|
assert session.window.isVisible()
|
||||||
|
assert session.outcome is None
|
||||||
|
|
||||||
|
ready: SignalBlocker = qtbot.waitSignal(
|
||||||
|
session.application_ready,
|
||||||
|
timeout=30_000,
|
||||||
|
)
|
||||||
|
ready.wait()
|
||||||
|
assert session.is_ready
|
||||||
|
assert session.trio_token is not None
|
||||||
|
assert session.cancel_scope is not None
|
||||||
|
|
||||||
|
completed: SignalBlocker
|
||||||
|
with qtbot.waitSignal(
|
||||||
|
session.completed,
|
||||||
|
timeout=30_000,
|
||||||
|
) as completed:
|
||||||
|
session.window.close()
|
||||||
|
|
||||||
|
assert completed.args == [session.outcome]
|
||||||
|
assert session.outcome is not None
|
||||||
|
assert session.outcome.unwrap() is None
|
||||||
|
assert session.cancel_scope.cancel_called
|
||||||
|
assert exited[-1] is session.main_widget
|
||||||
|
assert session.window.qtractor_session is None
|
||||||
|
assert _window._qt_win is None
|
||||||
|
assert tractor.current_actor(
|
||||||
|
err_on_no_runtime=False,
|
||||||
|
) is None
|
||||||
|
assert not qapp.closingDown()
|
||||||
|
assert (
|
||||||
|
qapp.quitOnLastWindowClosed(),
|
||||||
|
qapp.cursorFlashTime(),
|
||||||
|
qapp.styleSheet(),
|
||||||
|
) == qt_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('fail', [False, True])
|
||||||
|
def test_run_qtractor_blocking_adapter(
|
||||||
|
reg_addr: tuple[str, int],
|
||||||
|
tmp_path: Path,
|
||||||
|
fail: bool,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
The blocking CLI adapter must drive PyQt6 and unwrap its guest.
|
||||||
|
|
||||||
|
`run_qtractor()` called the removed `QApplication.exec_()` API,
|
||||||
|
failing before Trio could start. Run the real adapter in a fresh
|
||||||
|
process with an offscreen application and test-owned registry and
|
||||||
|
config. The guest either returns normally or raises a sentinel
|
||||||
|
error after registering its window for inspection.
|
||||||
|
|
||||||
|
Guest completion must quit `QApplication.exec()` without a timer
|
||||||
|
or external shutdown. A bounded subprocess wait detects hangs;
|
||||||
|
exit status, propagated error identity, and the cleared actor and
|
||||||
|
window slots prove both outcome paths finish structured teardown.
|
||||||
|
Process isolation keeps the CLI's Qt ownership out of pytest-qt.
|
||||||
|
|
||||||
|
'''
|
||||||
|
script: str = dedent(f'''
|
||||||
|
from PyQt6.QtGui import QResizeEvent
|
||||||
|
from PyQt6.QtWidgets import QWidget
|
||||||
|
import tractor
|
||||||
|
from piker.ui import _window
|
||||||
|
from piker.ui._exec import run_qtractor
|
||||||
|
|
||||||
|
class Widget(QWidget):
|
||||||
|
def on_win_resize(self, event: QResizeEvent) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
windows: list[QWidget] = []
|
||||||
|
error = RuntimeError('qtractor guest sentinel')
|
||||||
|
|
||||||
|
async def main(widget: Widget) -> None:
|
||||||
|
windows.append(widget.window)
|
||||||
|
assert {reg_addr!r} in (
|
||||||
|
tractor.current_actor().accept_addrs
|
||||||
|
)
|
||||||
|
if {fail!r}:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_qtractor(
|
||||||
|
func=main,
|
||||||
|
args=(),
|
||||||
|
main_widget_type=Widget,
|
||||||
|
tractor_kwargs={{
|
||||||
|
'registry_addrs': [{reg_addr!r}],
|
||||||
|
'tractor_runtime_overrides': {{
|
||||||
|
'piker_test_dir': {str(tmp_path)!r},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert {fail!r}
|
||||||
|
assert exc is error
|
||||||
|
else:
|
||||||
|
assert not {fail!r}
|
||||||
|
|
||||||
|
assert len(windows) == 1
|
||||||
|
assert windows[0].qtractor_session is None
|
||||||
|
assert _window._qt_win is None
|
||||||
|
assert tractor.current_actor(
|
||||||
|
err_on_no_runtime=False,
|
||||||
|
) is None
|
||||||
|
''')
|
||||||
|
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||||
|
[sys.executable, '-c', script],
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
'QT_QPA_PLATFORM': 'offscreen',
|
||||||
|
'XDG_CONFIG_HOME': str(tmp_path / 'config'),
|
||||||
|
'XDG_CONFIG_DIRS': str(tmp_path / 'system-config'),
|
||||||
|
},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
Loading…
Reference in New Issue