total-perspective-vortex 3.3.0__tar.gz → 3.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/PKG-INFO +4 -4
  2. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/README.md +3 -3
  3. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/pyproject.toml +1 -1
  4. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_entity.py +47 -0
  5. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_explain.py +18 -2
  6. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_helpers.py +85 -0
  7. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_resource_requirements.py +7 -0
  8. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_rules.py +59 -0
  9. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_resource_pool.py +139 -0
  10. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_shell.py +15 -0
  11. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/total_perspective_vortex.egg-info/PKG-INFO +4 -4
  12. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/entities.py +3 -1
  13. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/helpers.py +16 -28
  14. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/mapper.py +11 -17
  15. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/LICENSE +0 -0
  16. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/setup.cfg +0 -0
  17. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/setup.py +0 -0
  18. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_dryrunner_resource_pool.py +0 -0
  19. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_dump.py +0 -0
  20. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_basic.py +0 -0
  21. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_context.py +0 -0
  22. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_destinations.py +0 -0
  23. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_inheritance.py +0 -0
  24. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_merge_multiple.py +0 -0
  25. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_params_specific.py +0 -0
  26. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_rank.py +0 -0
  27. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_resource_pool_integration.py +0 -0
  28. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_resubmit.py +0 -0
  29. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_role.py +0 -0
  30. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_sample.py +0 -0
  31. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_user.py +0 -0
  32. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_mapper_weighted_choice.py +0 -0
  33. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tests/test_scenarios.py +0 -0
  34. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/total_perspective_vortex.egg-info/SOURCES.txt +0 -0
  35. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/total_perspective_vortex.egg-info/dependency_links.txt +0 -0
  36. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/total_perspective_vortex.egg-info/entry_points.txt +0 -0
  37. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/total_perspective_vortex.egg-info/requires.txt +0 -0
  38. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/total_perspective_vortex.egg-info/top_level.txt +0 -0
  39. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/__init__.py +0 -0
  40. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/__init__.py +0 -0
  41. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/dryrunner.py +0 -0
  42. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/dumper.py +0 -0
  43. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/formatter.py +0 -0
  44. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/linter.py +0 -0
  45. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/mypychecker.py +0 -0
  46. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/shell.py +0 -0
  47. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/test/__init__.py +0 -0
  48. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/test/mock_galaxy.py +0 -0
  49. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/commands/type_check_template.j2 +0 -0
  50. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/__init__.py +0 -0
  51. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/evaluator.py +0 -0
  52. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/explain.py +0 -0
  53. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/loader.py +0 -0
  54. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/resource_pool.py +0 -0
  55. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/resource_requirements.py +0 -0
  56. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/core/util.py +0 -0
  57. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/rules/__init__.py +0 -0
  58. {total_perspective_vortex-3.3.0 → total_perspective_vortex-3.3.1}/tpv/rules/gateway.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: total-perspective-vortex
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Summary: A library for routing entities (jobs, users or groups) to destinations in Galaxy
5
5
  Author: Galaxy Contributors
6
6
  Maintainer-email: Galaxy Committers <galaxy-committers@lists.galaxyproject.org>
@@ -63,12 +63,12 @@ GTN](https://training.galaxyproject.org/training-material/topics/admin/tutorials
63
63
  ### Installing latest released version
64
64
 
65
65
  Once configured in the Galaxy application, TPV will be automatically installed
66
- in the Galaxy environment. If you would like to manually install it instead, you
67
- can do so from [PyPI](https://pypi.org/project/total-perspective-vortex/) with
66
+ in the Galaxy environment. If you would like to manually install it for independent
67
+ CLI use instead, you can do so from [PyPI](https://pypi.org/project/total-perspective-vortex/) with
68
68
  the following command:
69
69
 
70
70
  ```python
71
- pip install total-perspective-vortex
71
+ pip install "total-perspective-vortex[cli]"
72
72
  ```
73
73
 
74
74
 
@@ -23,12 +23,12 @@ GTN](https://training.galaxyproject.org/training-material/topics/admin/tutorials
23
23
  ### Installing latest released version
24
24
 
25
25
  Once configured in the Galaxy application, TPV will be automatically installed
26
- in the Galaxy environment. If you would like to manually install it instead, you
27
- can do so from [PyPI](https://pypi.org/project/total-perspective-vortex/) with
26
+ in the Galaxy environment. If you would like to manually install it for independent
27
+ CLI use instead, you can do so from [PyPI](https://pypi.org/project/total-perspective-vortex/) with
28
28
  the following command:
29
29
 
30
30
  ```python
31
- pip install total-perspective-vortex
31
+ pip install "total-perspective-vortex[cli]"
32
32
  ```
33
33
 
34
34
 
@@ -14,7 +14,7 @@ authors = [
14
14
  maintainers = [
15
15
  {name = "Galaxy Committers", email = "galaxy-committers@lists.galaxyproject.org"}
16
16
  ]
17
- version = "3.3.0"
17
+ version = "3.3.1"
18
18
  dependencies = [
19
19
  "pydantic>=2",
20
20
  "cachetools>=3.1.0",
@@ -11,6 +11,53 @@ from tpv.rules import gateway
11
11
 
12
12
  class TestEntity(unittest.TestCase):
13
13
 
14
+ def test_rule_context_uses_local_copy_and_keeps_self_alias(self):
15
+ loader = TPVConfigLoader(
16
+ {
17
+ "tools": {
18
+ "test": {
19
+ "cores": 1,
20
+ "params": {},
21
+ "rules": [
22
+ {
23
+ "if": "self is entity and entity.cores == 1",
24
+ "execute": "entity.params['local_change'] = 'preserved'",
25
+ },
26
+ {"if": True, "cores": 4},
27
+ {
28
+ "if": "self is entity and entity.cores == 4",
29
+ "execute": "entity.mem = 8",
30
+ },
31
+ ],
32
+ }
33
+ }
34
+ }
35
+ )
36
+ original = loader.config.tools["test"]
37
+ context = {"entity": original, "self": original}
38
+ evaluated = original.evaluate_rules(context)
39
+ self.assertEqual(evaluated.cores, 4)
40
+ self.assertEqual(evaluated.mem, 8)
41
+ self.assertEqual(evaluated.params, {"local_change": "preserved"})
42
+ self.assertEqual(original.cores, 1)
43
+ self.assertIsNone(original.mem)
44
+ self.assertEqual(original.params, {})
45
+ self.assertIs(context["entity"], evaluated)
46
+ self.assertIs(context["self"], evaluated)
47
+ self.assertIsNot(evaluated, original)
48
+
49
+ def test_rule_context_initialized_when_no_rules_match(self):
50
+ for rules in ([], [{"if": False, "cores": 4}]):
51
+ with self.subTest(rules=rules):
52
+ loader = TPVConfigLoader({"tools": {"test": {"cores": 1, "rules": rules}}})
53
+ original = loader.config.tools["test"]
54
+ context = {}
55
+ evaluated = original.evaluate_rules(context)
56
+ self.assertIs(context["entity"], evaluated)
57
+ self.assertIs(context["self"], evaluated)
58
+ self.assertIsNot(evaluated, original)
59
+ self.assertEqual(evaluated.cores, 1)
60
+
14
61
  @staticmethod
15
62
  def _map_to_destination(app, job, tool, user, referrer=None):
16
63
  tpv_config = os.path.join(os.path.dirname(__file__), "fixtures/mapping-rule-argument-based.yml")
@@ -242,6 +242,20 @@ class TestDryRunExplain(unittest.TestCase):
242
242
  self.assertIn("Destination Ranking", trace)
243
243
  self.assertIn("Final Result", trace)
244
244
 
245
+ def test_dry_run_explain_unconfigured_tool_without_a_default(self):
246
+ """A tool with no entity in a config that sets no default_inherits still maps, via the
247
+ entity TPV builds from the Galaxy tool itself -- and the trace names that entity, which is
248
+ how an admin learns the tool got injected defaults rather than configured ones."""
249
+ dry_runner = TPVDryRunner.from_params(
250
+ job_conf=self._fixture_path("job_conf_dry_run.yml"),
251
+ tool_id="a_tool_nobody_configured",
252
+ user_email="fairycake@vortex.org",
253
+ tpv_confs=[self._fixture_path("mapping-inheritance-no-default.yml")],
254
+ )
255
+ destination, collector = dry_runner.run(explain=True)
256
+ self.assertIsNotNone(destination)
257
+ self.assertIn("matched entity 'tool_provided_resources_a_tool_nobody_configured'", collector.render())
258
+
245
259
  def test_dry_run_explain_shows_matched_entities(self):
246
260
  """The trace should show which tool and user entities matched."""
247
261
  dry_runner = TPVDryRunner.from_params(
@@ -501,7 +515,6 @@ class TestDryRunExplain(unittest.TestCase):
501
515
  self.assertIsNone(runner.tool)
502
516
 
503
517
 
504
-
505
518
  class TestGatewayExplainOnFailure(unittest.TestCase):
506
519
  """Tests for the tpv_explain_on_failure gateway config option."""
507
520
 
@@ -516,7 +529,10 @@ class TestGatewayExplainOnFailure(unittest.TestCase):
516
529
  job = mock_galaxy.Job()
517
530
  user = mock_galaxy.User("gargravarr", "fairycake@vortex.org")
518
531
  return gateway.map_tool_to_destination(
519
- app, job, tool, user,
532
+ app,
533
+ job,
534
+ tool,
535
+ user,
520
536
  referrer=referrer,
521
537
  tpv_config_files=[self._fixture_path("mapping-basic.yml")],
522
538
  explain_collector=explain_collector,
@@ -6,11 +6,15 @@ from unittest.mock import patch
6
6
 
7
7
  from tpv.commands.test import mock_galaxy
8
8
  from tpv.core.helpers import (
9
+ concurrent_job_count_for_tool,
9
10
  get_dataset_attributes,
10
11
  get_input_dataset,
11
12
  get_input_datasets,
12
13
  get_input_size,
13
14
  input_size,
15
+ job_args_match,
16
+ tool_version_eq,
17
+ tool_version_gte,
14
18
  weighted_choice,
15
19
  weighted_random_sampling,
16
20
  )
@@ -97,6 +101,13 @@ class TestHelpers(unittest.TestCase):
97
101
  # the existing input_size helper deduplicates the same way
98
102
  self.assertEqual(input_size(job), 8)
99
103
 
104
+ def test_get_input_size_skips_inputs_whose_dataset_is_gone(self):
105
+ # An input association can outlive its dataset (deleted or purged). It must contribute
106
+ # nothing rather than raise mid-scheduling.
107
+ job = self._job_with_multiple_data_param()
108
+ job.add_input_dataset(mock_galaxy.DatasetAssociation("gone", None))
109
+ self.assertEqual(get_input_size(job), 8)
110
+
100
111
  def test_get_input_size_by_param_name(self):
101
112
  job = self._job_with_multiple_data_param()
102
113
  job.add_input_dataset(
@@ -263,3 +274,77 @@ class TestHelpers(unittest.TestCase):
263
274
 
264
275
  self.assertIsInstance(result, dict)
265
276
  self.assertEqual(result, items[1])
277
+
278
+
279
+ class TestJobArgsMatch(unittest.TestCase):
280
+ """job_args_match decides whether a rule fires on a job's parameters. Its only test so far is
281
+ the positive case, which a helper that always returned True would pass."""
282
+
283
+ def setUp(self):
284
+ self.app = mock_galaxy.App(create_model=True)
285
+ self.job = mock_galaxy.Job()
286
+ self.job.param_values = {
287
+ "input_opts": {"db_selector": "db", "tabs_to_spaces": False},
288
+ "files": [{"name": "reads.fq"}],
289
+ }
290
+
291
+ def test_matches_only_when_every_given_value_matches(self):
292
+ self.assertTrue(job_args_match(self.job, self.app, {"input_opts": {"db_selector": "db"}}))
293
+ self.assertFalse(job_args_match(self.job, self.app, {"input_opts": {"db_selector": "other"}}))
294
+ # one matching and one differing value is not a match
295
+ self.assertFalse(
296
+ job_args_match(self.job, self.app, {"input_opts": {"db_selector": "db", "tabs_to_spaces": True}})
297
+ )
298
+
299
+ def test_a_parameter_the_job_does_not_have_does_not_match(self):
300
+ self.assertFalse(job_args_match(self.job, self.app, {"input_opts": {"no_such_option": 1}}))
301
+ self.assertFalse(job_args_match(self.job, self.app, {"no_such_section": {"x": 1}}))
302
+ # a nested dict in args where the job's parameter is a scalar is a mismatch, not an error
303
+ self.assertFalse(job_args_match(self.job, self.app, {"files": {"name": "reads.fq"}}))
304
+
305
+ def test_nothing_to_match_against_never_matches(self):
306
+ for args in (None, {}, "input_opts", ["input_opts"]):
307
+ with self.subTest(args=args):
308
+ self.assertFalse(job_args_match(self.job, self.app, args))
309
+
310
+ def test_a_list_valued_parameter_is_compared_as_a_whole(self):
311
+ # Repeats and multi-selects are lists. The list is the value; keys inside its items are
312
+ # not a path into it.
313
+ self.assertTrue(job_args_match(self.job, self.app, {"files": [{"name": "reads.fq"}]}))
314
+ self.assertFalse(job_args_match(self.job, self.app, {"files": [{"name": "other.fq"}]}))
315
+
316
+
317
+ class TestConcurrentJobCount(unittest.TestCase):
318
+ def test_plain_tool_ids_match_exactly_and_do_not_count_prefix_collisions(self):
319
+ # Tool Shed ids are matched by prefix so every installed version is counted together.
320
+ # A plain id must not get that treatment: "bwa" must not count "bwa_mem" jobs.
321
+ app = mock_galaxy.App(create_model=True)
322
+ session = app.model.context
323
+ user = app.model.User(username="arthur", email="arthur@vortex.org", password="x")
324
+ session.add(user)
325
+ session.flush()
326
+ for tool_id in ("bwa", "bwa", "bwa_mem"):
327
+ job = app.model.Job()
328
+ job.user = user
329
+ job.tool_id = tool_id
330
+ job.state = "running"
331
+ session.add(job)
332
+ session.flush()
333
+
334
+ self.assertEqual(concurrent_job_count_for_tool(app, mock_galaxy.Tool("bwa")), 2)
335
+ self.assertEqual(concurrent_job_count_for_tool(app, mock_galaxy.Tool("bwa_mem")), 1)
336
+
337
+
338
+ class TestToolVersionComparison(unittest.TestCase):
339
+ def test_versions_are_compared_as_versions_not_strings(self):
340
+ tool = mock_galaxy.Tool("t", version="1.2.0")
341
+ self.assertTrue(tool_version_eq(tool, "1.2")) # PEP 440: 1.2.0 == 1.2
342
+ self.assertTrue(tool_version_eq(tool, "1.2.0"))
343
+ self.assertFalse(tool_version_eq(tool, "1.3"))
344
+ self.assertTrue(tool_version_gte(tool, "1.10") is False) # not a string compare: "1.2" < "1.10"
345
+
346
+ def test_comparison_is_unknown_not_false_when_a_version_is_missing(self):
347
+ # A rule can tell "no version to compare" (None) from "compared and did not match" (False).
348
+ unversioned = mock_galaxy.Tool("t")
349
+ self.assertIsNone(tool_version_eq(unversioned, "1.0"))
350
+ self.assertIsNone(tool_version_gte(mock_galaxy.Tool("t", version="1.0"), None))
@@ -40,6 +40,13 @@ class TestMapperResourceRequirements(unittest.TestCase):
40
40
  result = extract_resource_requirements_from_tool(tool)
41
41
  self.assertEqual(result, {})
42
42
 
43
+ def test_requirement_types_tpv_does_not_map_are_ignored(self):
44
+ # Tools may declare tmpdir_min, outdir_min etc. TPV maps only cores and RAM; the rest must
45
+ # be skipped, not fail the tool.
46
+ reqs = [ResourceRequirement("4", "cores_min"), ResourceRequirement("10", "tmpdir_min")]
47
+ tool = mock_galaxy.Tool("test_tool", resource_requirements=reqs)
48
+ self.assertEqual(extract_resource_requirements_from_tool(tool), {"cores": 4})
49
+
43
50
  def test_extract_resource_requirements_from_tool_cores(self):
44
51
  cores_req = ResourceRequirement("4", "cores_min")
45
52
 
@@ -8,11 +8,45 @@ from galaxy.jobs import JobDestination
8
8
  from galaxy.jobs.mapper import JobMappingException, JobNotReadyException
9
9
 
10
10
  from tpv.commands.test import mock_galaxy
11
+ from tpv.core.entities import TryNextDestinationOrFail, TryNextDestinationOrWait
12
+ from tpv.core.loader import TPVConfigLoader
13
+ from tpv.core.mapper import EntityToDestinationMapper
11
14
  from tpv.rules import gateway
12
15
 
13
16
 
14
17
  class TestMapperRules(unittest.TestCase):
15
18
 
19
+ def _destination_context_mapper(self):
20
+ config = os.path.join(os.path.dirname(__file__), "fixtures/mapping-destination-context.yml")
21
+ return EntityToDestinationMapper(TPVConfigLoader.from_url_or_path(config))
22
+
23
+ def _map_destination_context(self, mapper):
24
+ app = mock_galaxy.App(job_conf=os.path.join(os.path.dirname(__file__), "fixtures/job_conf.yml"))
25
+ return mapper.map_to_destination(app, mock_galaxy.Tool("test"), None, mock_galaxy.Job())
26
+
27
+ def test_first_destination_rule_condition_sees_combined_destination(self):
28
+ mapper = self._destination_context_mapper()
29
+ destination = self._map_destination_context(mapper)
30
+ self.assertEqual(destination.id, "condor")
31
+ self.assertEqual(destination.params["inspected_destination"], "condor")
32
+ self.assertEqual(mapper.global_context["attempts"], ["condor"])
33
+
34
+ def test_first_destination_rule_execute_sees_combined_destination(self):
35
+ mapper = self._destination_context_mapper()
36
+ mapper.destinations["condor"].rules["inspect_destination"].if_condition = True
37
+ destination = self._map_destination_context(mapper)
38
+ self.assertEqual(destination.params["inspected_destination"], "condor")
39
+
40
+ def test_destination_fallback_refreshes_rule_context(self):
41
+ for rejection in (TryNextDestinationOrFail, TryNextDestinationOrWait):
42
+ with self.subTest(rejection=rejection.__name__):
43
+ mapper = self._destination_context_mapper()
44
+ mapper.global_context["rejection"] = rejection
45
+ destination = self._map_destination_context(mapper)
46
+ self.assertEqual(destination.id, "fallback")
47
+ self.assertEqual(destination.params["inspected_destination"], "fallback")
48
+ self.assertEqual(mapper.global_context["attempts"], ["condor", "fallback"])
49
+
16
50
  @staticmethod
17
51
  def _map_to_destination(
18
52
  tool,
@@ -128,6 +162,31 @@ class TestMapperRules(unittest.TestCase):
128
162
  ["8"],
129
163
  )
130
164
 
165
+ def test_invalid_edit_keeps_the_previous_rules_active(self):
166
+ """Hot reload must not take scheduling down on a bad edit: if the rewritten file fails to
167
+ load, the previously loaded rules stay in force and the failure is logged."""
168
+ with tempfile.NamedTemporaryFile("w+t") as tmp_file:
169
+ shutil.copy2(os.path.join(os.path.dirname(__file__), "fixtures/mapping-rules.yml"), tmp_file.name)
170
+ tool = mock_galaxy.Tool("bwa")
171
+ user = mock_galaxy.User("gargravarr", "fairycake@vortex.org")
172
+ datasets = [mock_galaxy.DatasetAssociation("test", mock_galaxy.Dataset("test.txt", file_size=5 * 1024**3))]
173
+
174
+ destination = self._map_to_destination(
175
+ tool, user, datasets, tpv_config_files=[tmp_file.name], reset_mappers=True
176
+ )
177
+ self.assertEqual([e["value"] for e in destination.env if e["name"] == "TEST_JOB_SLOTS_USER"], ["4"])
178
+
179
+ with open(tmp_file.name, "w") as f:
180
+ f.write("tools:\n default:\n cores: [this is not valid config\n")
181
+ with self.assertLogs("tpv.rules.gateway", level="WARNING") as logs:
182
+ time.sleep(2) # wait for the watcher to attempt the reload
183
+ self.assertTrue(any("Failed to reload" in line for line in logs.output), logs.output)
184
+
185
+ destination = self._map_to_destination(
186
+ tool, user, datasets, tpv_config_files=[tmp_file.name], reset_mappers=False
187
+ )
188
+ self.assertEqual([e["value"] for e in destination.env if e["name"] == "TEST_JOB_SLOTS_USER"], ["4"])
189
+
131
190
  def test_multiple_files_automatically_reload_on_update(self):
132
191
  with tempfile.NamedTemporaryFile("w+t") as tmp_file1, tempfile.NamedTemporaryFile("w+t") as tmp_file2:
133
192
  rule_file = os.path.join(os.path.dirname(__file__), "fixtures/mapping-rules.yml")
@@ -182,6 +182,16 @@ class TestStoreConfig(unittest.TestCase):
182
182
  self.assertEqual(StoreConfig().store_class, "tpv.core.resource_pool.ValkeyAllocationStore")
183
183
 
184
184
 
185
+ class TestStoreConfigErrors(unittest.TestCase):
186
+ def test_bare_class_name_is_rejected_with_a_clear_message(self):
187
+ from tpv.core.resource_pool import StoreConfig
188
+
189
+ # `class: InMemoryAllocationStore` (no module) is the likely typo; it must fail loudly at
190
+ # load, naming the problem, rather than with an AttributeError deep in importlib.
191
+ with self.assertRaisesRegex(ValueError, "dotted class path"):
192
+ StoreConfig.model_validate({"class": "InMemoryAllocationStore"}).build_store()
193
+
194
+
185
195
  class TestTerminalJobIds(unittest.TestCase):
186
196
  def test_returns_only_terminal_jobs(self):
187
197
  app = mock_galaxy.App(create_model=True)
@@ -589,6 +599,131 @@ class TestResourcePoolBranches(unittest.TestCase):
589
599
  EntityToDestinationMapper(loader)
590
600
 
591
601
 
602
+ class TestValkeyStoreBackendErrors(unittest.TestCase):
603
+ """Fail-closed rests on the adapter turning backend errors into StoreUnavailable, which the
604
+ mapper turns into deferral. Every other fail-closed test raises StoreUnavailable directly;
605
+ this one drives real redis exceptions through the adapter, so the translation itself is
606
+ what is under test."""
607
+
608
+ @staticmethod
609
+ def _store_whose_backend_raises(error):
610
+ class Client:
611
+ def hgetall(self, key):
612
+ raise error
613
+
614
+ def register_script(self, script):
615
+ def run(keys, args):
616
+ raise error
617
+
618
+ return run
619
+
620
+ return ValkeyAllocationStore(client=Client())
621
+
622
+ @staticmethod
623
+ def _admission():
624
+ return PoolAdmission("p", ResourceUsage(1, 0, 0), NORMAL, Budget(), 0, False, set())
625
+
626
+ def test_redis_errors_become_store_unavailable(self):
627
+ import redis
628
+
629
+ for error in (redis.exceptions.ConnectionError("refused"), redis.exceptions.TimeoutError("slow")):
630
+ with self.subTest(error=type(error).__name__):
631
+ store = self._store_whose_backend_raises(error)
632
+ with self.assertRaises(StoreUnavailable):
633
+ store.read("p", 1)
634
+ with self.assertRaises(StoreUnavailable):
635
+ store.admit_many(1, 1, [self._admission()])
636
+
637
+ def test_other_errors_are_not_reported_as_an_outage(self):
638
+ # A bug in our own code must surface as itself. Reporting it as "store unavailable" would
639
+ # silently defer every governed job with a misleading warning.
640
+ store = self._store_whose_backend_raises(TypeError("bug"))
641
+ with self.assertRaises(TypeError):
642
+ store.read("p", 1)
643
+ with self.assertRaises(TypeError):
644
+ store.admit_many(1, 1, [self._admission()])
645
+
646
+ def test_valkey_url_schemes_map_to_the_redis_schemes_redis_py_understands(self):
647
+ import redis
648
+
649
+ cases = [
650
+ ("valkey://host:6379/0", "redis://host:6379/0"),
651
+ ("valkeys://host:6379/0", "rediss://host:6379/0"), # TLS
652
+ ("redis://host:6379/0", "redis://host:6379/0"),
653
+ ]
654
+ for given, expected in cases:
655
+ with self.subTest(url=given), patch.object(redis, "from_url") as from_url:
656
+ ValkeyAllocationStore(url=given)
657
+ self.assertEqual(from_url.call_args.args[0], expected)
658
+
659
+
660
+ class TestResourcePoolExplain(unittest.TestCase):
661
+ """The explain trace is how an operator finds out why a job is stuck. The happy path and
662
+ permanent rejection are covered by the dry-run tests; these pin the messages for the
663
+ outcomes that actually leave a job waiting -- a full pool and a store outage -- and that the
664
+ fail_open variant says so rather than looking like a normal admission."""
665
+
666
+ def _mapper(self):
667
+ return EntityToDestinationMapper(TPVConfigLoader.from_url_or_path(FIXTURE))
668
+
669
+ @staticmethod
670
+ def _job(job_id):
671
+ job = mock_galaxy.Job()
672
+ job.id = job_id
673
+ return job
674
+
675
+ def _trace(self, mapper, tool, expect=None):
676
+ from tpv.core.explain import ExplainCollector
677
+
678
+ collector = ExplainCollector()
679
+ user = mock_galaxy.User("arthur", "arthur@vortex.org", id=1)
680
+ call = lambda: mapper.map_to_destination( # noqa: E731
681
+ mock_galaxy.App(create_model=True), mock_galaxy.Tool(tool), user, self._job(1), explain_collector=collector
682
+ )
683
+ if expect is None:
684
+ call()
685
+ else:
686
+ with self.assertRaises(expect):
687
+ call()
688
+ return collector.render()
689
+
690
+ def test_deferral_names_the_full_pool(self):
691
+ mapper = self._mapper()
692
+ _seed(mapper.resource_pools.store, "default", 1, 900, ResourceUsage(30, 0, 0))
693
+ trace = self._trace(mapper, "default", expect=JobNotReadyException)
694
+ self.assertIn("DEFERRED", trace)
695
+ self.assertIn("default", trace)
696
+
697
+ def test_store_outage_is_reported_with_the_policy_that_decided_it(self):
698
+ for fail_open, expect, message in (
699
+ (False, JobNotReadyException, "fail-closed -> deferred"),
700
+ (True, None, "fail_open -> admitted"),
701
+ ):
702
+ with self.subTest(fail_open=fail_open):
703
+ mapper = self._mapper()
704
+ mapper.resource_pools.store = _RaisingStore()
705
+ mapper.pools["default"].fail_open = fail_open
706
+ trace = self._trace(mapper, "default", expect=expect)
707
+ self.assertIn("store unavailable", trace)
708
+ self.assertIn(message, trace)
709
+
710
+ def test_outage_during_the_batch_write_is_reported(self):
711
+ # The read succeeded and every pool resolved; the single atomic write then failed. The
712
+ # trace must say the store was the problem, not that the pool was full.
713
+ for all_fail_open, expect, message in (
714
+ (False, JobNotReadyException, "fail-closed -> deferred"),
715
+ (True, None, "every pool is fail_open -> admitted"),
716
+ ):
717
+ with self.subTest(all_fail_open=all_fail_open):
718
+ mapper = self._mapper()
719
+ mapper.pools["default"].fail_open = all_fail_open
720
+ with patch.object(mapper.resource_pools.store, "admit_many", side_effect=StoreUnavailable("down")):
721
+ trace = self._trace(mapper, "default", expect=expect)
722
+ self.assertIn("Store unavailable", trace)
723
+ self.assertIn(message, trace)
724
+ self.assertNotIn("DEFERRED: user", trace)
725
+
726
+
592
727
  def _build_valkey_store():
593
728
  """A ValkeyAllocationStore backed by fakeredis, or None when fakeredis+Lua is unavailable.
594
729
 
@@ -669,6 +804,10 @@ PARITY_SCENARIOS = {
669
804
  _op(1, ResourceUsage(64, 0, 0), kind=OVERSIZE, max_oversize=1, reserve_pool=True),
670
805
  _op(2, ResourceUsage(1, 0, 0), kind=NORMAL, max_oversize=1, reserve_pool=True),
671
806
  ],
807
+ "reserve_pool_blocks_oversize_while_normal_runs": [
808
+ _op(1, ResourceUsage(1, 0, 0), kind=NORMAL, max_oversize=1, reserve_pool=True),
809
+ _op(2, ResourceUsage(64, 0, 0), kind=OVERSIZE, max_oversize=1, reserve_pool=True),
810
+ ],
672
811
  "drop_releases": [_op(1, ResourceUsage(30, 0, 0)), _op(2, ResourceUsage(8, 0, 0), drop=(1,))],
673
812
  }
674
813
 
@@ -152,6 +152,13 @@ class TPVShellTestCase(unittest.TestCase):
152
152
  f"Expected Name 'mem2' is not defined but output was: {output}",
153
153
  )
154
154
 
155
+ def test_lint_types_checks_role_rules(self):
156
+ # Every other type-check test puts the bad expression on a tool. A regression that skipped
157
+ # roles when gathering code blocks would leave role rules unchecked and pass those tests.
158
+ tpv_config = os.path.join(os.path.dirname(__file__), "fixtures/linter/linter-types-role-rule.yml")
159
+ output = self.call_shell_command("tpv", "-vv", "lint", tpv_config)
160
+ self.assertIn('error: Name "training_quota_exceeded" is not defined', output, output)
161
+
155
162
  def test_lint_types_legacy_tagset_reference(self):
156
163
  tpv_config = os.path.join(
157
164
  os.path.dirname(__file__),
@@ -773,6 +780,14 @@ class TPVShellTestCase(unittest.TestCase):
773
780
  self.assertIn("bwa:", output)
774
781
  self.assertIn("local:", output)
775
782
 
783
+ def test_dump_includes_resource_pools(self):
784
+ # dump is how an admin inspects the merged config; pools are a top-level section like tools.
785
+ tpv_config = os.path.join(os.path.dirname(__file__), "fixtures/mapping-resource-pool.yml")
786
+ output = self.call_shell_command("tpv", "dump", tpv_config)
787
+ self.assertIn("--- Pools ---", output, output)
788
+ self.assertIn("gpu:", output)
789
+ self.assertIn("max_concurrent_gpus", output)
790
+
776
791
  def test_dump_multiple_configs(self):
777
792
  remote = os.path.join(os.path.dirname(__file__), "fixtures/mapping-merge-multiple-remote.yml")
778
793
  local = os.path.join(os.path.dirname(__file__), "fixtures/mapping-merge-multiple-local.yml")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: total-perspective-vortex
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Summary: A library for routing entities (jobs, users or groups) to destinations in Galaxy
5
5
  Author: Galaxy Contributors
6
6
  Maintainer-email: Galaxy Committers <galaxy-committers@lists.galaxyproject.org>
@@ -63,12 +63,12 @@ GTN](https://training.galaxyproject.org/training-material/topics/admin/tutorials
63
63
  ### Installing latest released version
64
64
 
65
65
  Once configured in the Galaxy application, TPV will be automatically installed
66
- in the Galaxy environment. If you would like to manually install it instead, you
67
- can do so from [PyPI](https://pypi.org/project/total-perspective-vortex/) with
66
+ in the Galaxy environment. If you would like to manually install it for independent
67
+ CLI use instead, you can do so from [PyPI](https://pypi.org/project/total-perspective-vortex/) with
68
68
  the following command:
69
69
 
70
70
  ```python
71
- pip install total-perspective-vortex
71
+ pip install "total-perspective-vortex[cli]"
72
72
  ```
73
73
 
74
74
 
@@ -565,6 +565,8 @@ class EntityWithRules(Entity):
565
565
  def evaluate_rules(self, context: dict[str, Any]) -> Self:
566
566
  new_entity = copy.deepcopy(self)
567
567
  context.update(new_entity.context or {})
568
+ # Conditions and execute blocks must see the local entity, including for each destination candidate.
569
+ context.update({"entity": new_entity, "self": new_entity})
568
570
  explain = ExplainCollector.from_context(context)
569
571
  for rule in self.rules.values():
570
572
  if rule.is_matching(context):
@@ -590,7 +592,7 @@ class EntityWithRules(Entity):
590
592
  new_entity.cores = rule.cores or new_entity.cores
591
593
  new_entity.mem = rule.mem or new_entity.mem
592
594
  new_entity.id = f"{new_entity.id}, Rule: {rule.id}"
593
- context.update({"entity": new_entity})
595
+ context.update({"entity": new_entity, "self": new_entity})
594
596
  else:
595
597
  if explain:
596
598
  explain.add_step(
@@ -123,7 +123,9 @@ def get_input_size(
123
123
  """
124
124
  total = 0.0
125
125
  for dataset in get_input_datasets(job, param_name):
126
- if not dataset.dataset:
126
+ # get_input_datasets already drops associations without a dataset; this narrows the
127
+ # Optional type for mypy and cannot run.
128
+ if not dataset.dataset: # pragma: no cover
127
129
  continue
128
130
  multiplier = 1.0
129
131
  if estimate_uncompressed_size and (dataset.extension or "").endswith(COMPRESSED_EXTENSION_SUFFIXES):
@@ -166,38 +168,24 @@ def weighted_choice(items: list[WeightedT]) -> WeightedT:
166
168
  return _weighted_draw(items, [item.get("weight", 1) for item in items], k=1)[0]
167
169
 
168
170
 
169
- def __get_keys_from_dict(dl: Any, keys_list: list[str]) -> None:
170
- # This function builds a list using the keys from nested dictionaries
171
- # (copied from galaxyproject/galaxy lib/galaxy/jobs/dynamic_tool_destination.py)
172
- if isinstance(dl, dict):
173
- keys_list.extend(dl.keys())
174
- for x in dl.values():
175
- __get_keys_from_dict(x, keys_list)
176
- elif isinstance(dl, list):
177
- for x in dl:
178
- __get_keys_from_dict(x, keys_list)
171
+ def __args_match(args: Any, params: Any) -> bool:
172
+ # Every key in `args` must be present in `params`, recursing through nested dicts. Anything
173
+ # that is not a dict -- scalars and lists alike -- is a leaf and must compare equal, so a
174
+ # list-valued parameter (a repeat or multi-select) is matched as a whole.
175
+ if isinstance(args, dict):
176
+ if not isinstance(params, dict):
177
+ return False
178
+ return all(key in params and __args_match(value, params[key]) for key, value in args.items())
179
+ return bool(args == params)
179
180
 
180
181
 
181
182
  def job_args_match(job: Job, app: UniverseApplication, args: dict[str, Any] | None) -> bool:
182
- # Check whether a dictionary of arguments matches a job's parameters. This code is
183
- # from galaxyproject/galaxy lib/galaxy/jobs/dynamic_tool_destination.py
183
+ """Whether every argument in ``args`` matches the job's parameters, e.g.
184
+ ``{'input_opts': {'db_selector': 'db'}}`` matches a job whose ``input_opts|db_selector`` is
185
+ ``db``. Nested dicts are matched key by key; lists and scalars must be equal."""
184
186
  if not args or not isinstance(args, dict):
185
187
  return False
186
- options = job.get_param_values(app) # type: ignore[no-untyped-call]
187
- matched = True
188
- # check if the args in the config file are available
189
- for arg in args:
190
- arg_dict = {arg: args[arg]}
191
- arg_keys_list: list[str] = []
192
- __get_keys_from_dict(arg_dict, arg_keys_list)
193
- try:
194
- options_value = reduce(dict.__getitem__, arg_keys_list, options)
195
- arg_value = reduce(dict.__getitem__, arg_keys_list, arg_dict)
196
- if arg_value != options_value:
197
- matched = False
198
- except KeyError:
199
- matched = False
200
- return matched
188
+ return __args_match(args, job.get_param_values(app)) # type: ignore[no-untyped-call]
201
189
 
202
190
 
203
191
  def concurrent_job_count_for_tool(
@@ -171,10 +171,10 @@ class EntityToDestinationMapper(object):
171
171
  def __apply_default_destination_inheritance(
172
172
  self, entity_list: dict[str, Destination], context: Mapping[str, Any]
173
173
  ) -> list[Destination]:
174
+ # Never empty: _get_common_inherits always contributes the tool_type_secure_defaults
175
+ # destination, so every destination inherits at least that.
174
176
  inherited_defaults = self._get_common_inherits(context, entity_list, Destination)
175
- if inherited_defaults:
176
- return [self.inherit_entities([*inherited_defaults, entity]) for entity in entity_list.values()]
177
- return list(entity_list.values())
177
+ return [self.inherit_entities([*inherited_defaults, entity]) for entity in entity_list.values()]
178
178
 
179
179
  def inherit_entities(self, entities: list[EntityType]) -> EntityType:
180
180
  return functools.reduce(lambda a, b: b.inherit(a), entities)
@@ -270,21 +270,15 @@ class EntityToDestinationMapper(object):
270
270
  tool_id = f"{tool.tool_type}-{tool.dynamic_tool.uuid}"
271
271
  else:
272
272
  tool_id = tool.id or "unknown_tool_id"
273
+ # Never None: _get_common_inherits always contributes the tool_provided_resources entity
274
+ # built from the Galaxy tool itself, so an unconfigured tool still resolves to an entity.
273
275
  tool_entity = self.inherit_matching_entities(context, Tool, "tools", tool_id)
274
-
275
- if not tool_entity:
276
- tool_entity = Tool(evaluator=self.loader, id=tool_id)
277
- if explain:
278
- explain.add_step(
279
- ExplainPhase.ENTITY_MATCHING,
280
- f"Tool '{tool_id}': no explicit match, using default",
281
- )
282
- else:
283
- if explain:
284
- explain.add_step(
285
- ExplainPhase.ENTITY_MATCHING,
286
- f"Tool '{tool_id}': matched entity '{tool_entity.id}'",
287
- )
276
+ assert tool_entity is not None
277
+ if explain:
278
+ explain.add_step(
279
+ ExplainPhase.ENTITY_MATCHING,
280
+ f"Tool '{tool_id}': matched entity '{tool_entity.id}'",
281
+ )
288
282
 
289
283
  entity_list: list[EntityWithRules] = [tool_entity]
290
284