Target: galaxyproject/pulsar#458 (branch pulsar-capabilities).
This plan combines two coupled changes:
- Part A — make capability collection lazy (resolves review issue #2: collection
runs unconditionally in
PulsarApp.__init__for every deployment mode, but is only ever consumed by the relay publish path). Also incidentally deletes review issue #1 (the staleget_capabilitiescomment lives in the method Part A removes). - Part B — re-anchor the new tests on real, constructible Pulsar objects instead of bespoke duck-typed fakes, reusing existing test abstractions and adding one small, broadly-useful extension.
The two are deliberately rolled together: Part A deletes the cache attribute that the heaviest mocks in Part B exist to satisfy, so doing them separately would mean writing mock scaffolding in Part B only to delete it.
pulsar/core.py:54callsself.__setup_capabilities()fromPulsarApp.__init__(between__setup_managersand__setup_file_cache). The method:def __setup_capabilities(self): # Snapshot of static config + host probes that get_capabilities <-- issue #1: no such symbol # publishes to the relay. Computed once; stays valid for this # process's lifetime since pulsar doesn't reload app.yml. self.capabilities_by_manager = collect_all_capabilities(self)
pulsar/core.py:18:from pulsar.capabilities import collect_all_capabilities.pulsar/messaging/bind_relay.py:publish_manager_capabilities_to_relayreads the precomputed cache:capabilities = (getattr(app, "capabilities_by_manager", {}) or {}).get(manager.name) if capabilities is None: log.warning("No cached capabilities for manager %s; skipping relay publish.", manager.name) return
pulsar/messaging/__init__.py:bind_apprelay branch already passesapp, manager, relay_transport, merged_confinto the publisher — no signature change needed.- Only non-test consumers of the capabilities module are
core.py:131(collect) andbind_relay.py:281(read). No web/REST endpoint, no AMQP path, no client use. pulsar.capabilitiesruntime imports are stdlib +galaxy.tool_util.deps.resolvers.condapulsar.__version__only (nopulsar.core/pulsar.messagingat runtime;coreisTYPE_CHECKING-only). A top-levelfrom pulsar.capabilities import collect_capabilitiesinbind_relay.pyintroduces no import cycle.
test/test_utils.py::minimal_app_for_managers()→Bunch(staging_directory, authorizer, job_metrics, dependency_manager=TestDependencyManager(), user_auth_manager, object_store). Missingpersistence_directory; itsTestDependencyManagerhas nodependency_resolvers/default_base_path.test/test_utils.py::TestDependencyManager— onlydependency_shell_commands.test/persistence_test.py::_app()— near-duplicate ofminimal_app_for_managers()but already addspersistence_directoryand constructs realStatefulManagerProxy(QueueManager('test', app, num_concurrent_jobs=N)). This is the canonical lightweight real-manager pattern (no web boot, no job recovery).pulsar/managers/queued.py:QueueManager.manager_type = "queued_python"is a class attribute (sogetattr(type(underlying), "manager_type", ...)works on a real instance);__init__buildsself.work_threadsof lengthnum_concurrent_jobs(solen(work_threads)is exercised for real).- Blast radius of touching shared helpers is tiny:
minimal_app_for_managers()has 2 consumers (manager_factory_test.py:62,BaseManagerTestCase.setUp);TestDependencyManagerhas 2 (persistence_test.py:129, insideminimal_app_for_managers). Both proposed changes are purely additive.
- Delete the
from pulsar.capabilities import collect_all_capabilitiesimport (line 18). - Delete the
self.__setup_capabilities()call (line 54). - Delete the
__setup_capabilitiesmethod entirely (this removes the issue-#1 stale comment as a side effect — no separate fix needed).
-
Add top-level import:
from pulsar.capabilities import collect_capabilities. -
Rewrite the body of
publish_manager_capabilities_to_relayso it collects on demand and preserves the "advisory, must not block bind" guarantee for both collection and POST failures:def publish_manager_capabilities_to_relay(app, manager, relay_transport, conf): if not conf.get("message_queue_publish_capabilities", True): return try: capabilities = collect_capabilities(app, manager) except Exception: log.exception( "Failed to collect capabilities for manager %s; skipping relay publish.", manager.name, ) return relay_topic_prefix = conf.get('relay_topic_prefix', '') topic = __make_capabilities_topic_name(relay_topic_prefix, manager.name) payload = capabilities.to_dict() payload["published_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat() try: relay_transport.post_message(topic, payload) log.info("Published capabilities for manager %s to relay topic %s", manager.name, topic) except Exception: log.exception( "Failed to publish capabilities for manager %s to relay topic %s", manager.name, topic, )
-
The
getattr(app, "capabilities_by_manager", {})lookup and the"No cached capabilities"warning branch are deleted (the concept no longer exists).
- Remove
collect_all_capabilities(now unused — single consumer, single trigger). Keepcollect_capabilitiesand all helpers/dataclasses unchanged.- If a reviewer prefers to keep
collect_all_capabilitiesas public API, the fallback is to leave it but mark it unused; default recommendation is removal to avoid dead code.
- If a reviewer prefers to keep
- No change (already passes
app, manager, relay_transport, merged_conf).
Collection happens iff relay messaging mode is active and
message_queue_publish_capabilities is true and for each manager actually being
bound. AMQP, web-only, and coexecution deployments do zero capability work and emit no
capability log noise. The wire payload and topic naming are byte-for-byte unchanged
(resilience scenario test/resilience/scenarios/test_capabilities_publish.py stays
valid as-is — but the implementer must actually re-run that scenario, since it is the
only end-to-end coverage of the relocated collect→publish path).
This is not new error-isolation behavior: a per-manager collection failure already
resulted in no snapshot for that manager (today via the "No cached capabilities"
warning). Post–Part A it is the collect-time try/except instead, logged as an
exception. The Galaxy-visible outcome is identical (no snapshot ⇒ Galaxy keeps using
operator-supplied params); only where the failure is contained moves.
- Extend
TestDependencyManagerso it satisfies the collector contract and can inject resolvers for tests that need them:(Default-constructed behavior is unchanged for the 2 existing consumers.)class TestDependencyManager: def __init__(self, dependency_resolvers=None, default_base_path=None): self.dependency_resolvers = dependency_resolvers or [] self.default_base_path = default_base_path def dependency_shell_commands(self, requirements, **kwds): return []
- Add
persistence_directorytominimal_app_for_managers()(mirrors whatpersistence_test._app()already does; additive — neither existing consumer breaks). - Promote the publisher test's
_RecordingTransportintotest_utilsas a sharedRecordingRelayTransportfake (records(topic, payload); optionalraise_on_post). Rationale: the relay HTTP transport is a genuine external boundary — a recording fake (not amock.Mock) is the correct and reusable double for it.
Non-goal / out of scope: consolidating
persistence_test._app()intotest_utils. Noted as a future cleanup; not done here to keep blast radius minimal.
- Keep unchanged (these are good — real logic, only OS-boundary
shutil.whichpatched, realCondaDependencyResolversubclass instance):test_conda_*(5 cases),test_detect_container_runtime_cross_product,test_collects_dependency_resolver_attributes,test_resolver_without_resolver_type_is_skipped,_make_conda_resolverhelper (real subclass via__new__is acceptable:__init__is genuinely heavy andisinstanceis the production contract).
- Delete the bespoke
_Manager/_Appclasses and the genuinely tautological tests that only assert a hand-built fake is read back (each is fully covered by the real-QueueManagertest below, which a real object exercises authentically):test_minimal_app_collects_clean_payload,test_stateful_manager_proxy_unwrap_for_manager_type,test_num_concurrent_jobs_prefers_work_threads_len,test_collect_all_capabilities_returns_per_manager_dict.
- Keep, narrowed to a minimal
_collect_managerunit (do not delete):test_num_concurrent_jobs_falls_back_to_attrandtest_num_concurrent_jobs_none_when_neither. These cover thegetattr(underlying, "num_concurrent_jobs", None)fallback atpulsar/capabilities.py:186-188. No in-tree production manager sets anum_concurrent_jobsinstance attribute (verified: grep ofpulsar/managers/is empty), so this branch is unreachable by any real object — it exists purely as a defensive hook for hypothetical third-party manager subclasses. A tiny duck-typed stub is therefore the correct tool here, not mock theater: the real-object test cannot reach this branch, so deleting these would be a genuine branch-coverage regression, not removal of a tautology. Retarget them at_collect_managerdirectly with a 3-line stub and a comment stating exactly why a stub is appropriate for an intentionally-third-party-only branch. - Replace with real-object equivalents using existing patterns
(
persistence_test.pystyle), e.g.:This exercises the realdef test_collect_capabilities_against_real_queue_manager(): app = minimal_app_for_managers() # extended in B1 proxy = StatefulManagerProxy(QueueManager('_default_', app, num_concurrent_jobs=4)) try: caps = collect_capabilities(app, proxy) assert caps.manager.type == "queued_python" # real class attr assert caps.manager.num_concurrent_jobs == 4 # real work_threads len assert caps.staging_directory == app.staging_directory assert caps.persistence_directory == app.persistence_directory assert caps.dependency_resolvers == [] finally: proxy.shutdown()
StatefulManagerProxy→_proxied_manager→ classmanager_type→work_threadscontract, so it actually fails if that contract drifts. test_to_dict_round_trips_through_json— keep, retargeted onto the real-manager caps (still a cheap, valuable serialization guard).collect_all_capabilitiestests removed (function deleted in A3). The failure-isolation behavior it tested now lives in the publisher's collect-timetry/except— covered by a new publisher test in B3.
- Delete
_FakeApp,_FakeManager,_RecordingTransport(the first two only existed to satisfy the now-deleted cache; the transport moves totest_utils). - Build the publisher's inputs from the same real objects as B2 + the shared
RecordingRelayTransport. The transport is the only fake — and it is the correct thing to fake (external HTTP boundary). - Keep (retargeted onto real app/manager +
RecordingRelayTransport):- one POST with expected topic +
schema_version+manager_name+published_at - topic prefix applied
- non-default manager → suffixed topic
- off-switch (
message_queue_publish_capabilities: False) skips POST RelayTransportErrorfrompost_messageswallowed (real exception type)published_atis ISO-8601 with tzinfotest_make_capabilities_topic_name_examples(pure function — unchanged)- Python <3.10 module skip guard — unchanged.
- one POST with expected topic +
- Delete
test_missing_capabilities_logs_warning_does_not_post(cache concept removed). Replace with a collect-failure test that pins the new bind-must-not-block guarantee enforced at collect time in A2: assert that when collection raises, no POST happens, the error is logged, and no exception propagates. A correctly-built realStatefulManagerProxy(QueueManager(...))cannot makecollect_capabilitiesraise, so this test deliberately patches the seam:mock.patch("pulsar.messaging.bind_relay.collect_capabilities", side_effect=RuntimeError(...)). This is a legitimate seam patch — the subject under test is the publisher's error isolation, not the collector — but it is a remaining mock and must be counted as one (see inventory below), not hand-waved away.
After the rewrite the doubles are, in full:
RecordingRelayTransport— a recording fake for the real external relay HTTP boundary (correct and now shared/reusable).mock.patch(shutil.which)— an OS-boundary patch in the pure detector tests (correct).mock.patch("pulsar.messaging.bind_relay.collect_capabilities", side_effect=...)— exactly one seam patch, in the single collect-failure publisher test, because no correctly-built real object can force a collection failure (correct: the seam is the right place to inject the fault when the subject is the publisher's error isolation).- A 3-line duck-typed stub in the two
_collect_managernum_concurrent_jobs-fallback unit tests, because that branch is unreachable by any in-tree real manager (correct: a stub is the only way to test an intentionally-third-party-only defensive branch).
This is the honest, complete list — the goal is minimal, justified doubles at real boundaries, not zero doubles.
No _App, _FakeApp, _Manager, _FakeManager, _DepMgr, _Resolver. The collector
and publisher are tested against real PulsarApp-shaped bunches and real
StatefulManagerProxy(QueueManager(...)), so the tests now catch real contract drift.
tox -e py310-test test/capabilities_test.py test/messaging_capabilities_test.py \
test/persistence_test.py test/manager_factory_test.py
tox -e lint
tox -e mypy
Plus confirm the resilience scenario is unaffected (no code change to the wire path):
test/resilience/scenarios/test_capabilities_publish.py should still pass in the
relay mode it already targets.
- No import cycle from the new top-level
bind_relay → pulsar.capabilitiesimport (expected clean per analysis above; verifypython -c "import pulsar.messaging"). minimal_app_for_managers()gainingpersistence_directorydoes not perturbmanager_factory_test.pyorBaseManagerTestCase(expected: additive, no reader).- Real
QueueManagerworker threads are shut down in every new test (try/finally proxy.shutdown()), so the suite doesn't leak threads.
- No change to the capability wire schema or topic naming (
schema_versionstays 1). - No consolidation of
persistence_test._app()intotest_utils(future cleanup). - No introduction of a heavy real-
PulsarAppboot in unit tests — the lightweightminimal_app_for_managers()+ realStatefulManagerProxyis the intended weight.
- Collection is now provably coupled to its only consumer; non-relay operators stop paying for and log-spamming a feature they don't use (issue #2 resolved; issue #1 deleted for free).
- The collector tests now assert against the real
StatefulManagerProxy/QueueManager/dependency-manager contracts for the paths a real object exercises (manager_type,work_threadslength, staging/persistence dirs, resolver list) — these fail on real contract drift where the current hand-built fakes encode the same assumptions as the code and would stay green. The one branch a real object cannot exercise (num_concurrent_jobsfallback) keeps a deliberately-minimal stub test, by design, not omission. - The one added abstraction (
TestDependencyManagerresolver fields +persistence_directoryonminimal_app_for_managers+ sharedRecordingRelayTransport) is reusable by future manager/relay tests, not capabilities-specific scaffolding.