total-perspective-vortex 3.2.0__tar.gz → 3.3.0__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 (59) hide show
  1. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/PKG-INFO +6 -1
  2. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/pyproject.toml +14 -1
  3. total_perspective_vortex-3.3.0/tests/test_dryrunner_resource_pool.py +60 -0
  4. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_entity.py +2 -2
  5. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_explain.py +59 -0
  6. total_perspective_vortex-3.3.0/tests/test_helpers.py +265 -0
  7. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_context.py +1 -1
  8. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_params_specific.py +4 -1
  9. total_perspective_vortex-3.3.0/tests/test_mapper_resource_pool_integration.py +300 -0
  10. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_resource_requirements.py +14 -5
  11. total_perspective_vortex-3.3.0/tests/test_mapper_weighted_choice.py +59 -0
  12. total_perspective_vortex-3.3.0/tests/test_resource_pool.py +760 -0
  13. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_scenarios.py +2 -2
  14. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_shell.py +56 -0
  15. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/PKG-INFO +6 -1
  16. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/SOURCES.txt +5 -0
  17. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/requires.txt +6 -0
  18. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/dryrunner.py +18 -4
  19. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/dumper.py +2 -0
  20. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/linter.py +1 -1
  21. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/mypychecker.py +1 -2
  22. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/shell.py +9 -4
  23. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/test/mock_galaxy.py +19 -8
  24. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/entities.py +94 -17
  25. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/explain.py +1 -0
  26. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/helpers.py +119 -9
  27. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/loader.py +5 -0
  28. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/mapper.py +202 -9
  29. total_perspective_vortex-3.3.0/tpv/core/resource_pool.py +393 -0
  30. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/resource_requirements.py +6 -0
  31. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/rules/gateway.py +27 -14
  32. total_perspective_vortex-3.2.0/tests/test_helpers.py +0 -59
  33. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/LICENSE +0 -0
  34. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/README.md +0 -0
  35. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/setup.cfg +0 -0
  36. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/setup.py +0 -0
  37. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_dump.py +0 -0
  38. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_basic.py +0 -0
  39. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_destinations.py +0 -0
  40. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_inheritance.py +0 -0
  41. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_merge_multiple.py +0 -0
  42. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_rank.py +0 -0
  43. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_resubmit.py +0 -0
  44. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_role.py +0 -0
  45. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_rules.py +0 -0
  46. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_sample.py +0 -0
  47. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_user.py +0 -0
  48. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/dependency_links.txt +0 -0
  49. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/entry_points.txt +0 -0
  50. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/top_level.txt +0 -0
  51. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/__init__.py +0 -0
  52. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/__init__.py +0 -0
  53. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/formatter.py +0 -0
  54. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/test/__init__.py +0 -0
  55. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/type_check_template.j2 +0 -0
  56. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/__init__.py +0 -0
  57. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/evaluator.py +0 -0
  58. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/util.py +0 -0
  59. {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/rules/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: total-perspective-vortex
3
- Version: 3.2.0
3
+ Version: 3.3.0
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>
@@ -10,6 +10,7 @@ Project-URL: Documentation, https://total-perspective-vortex.readthedocs.io
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
+ Requires-Dist: pydantic>=2
13
14
  Requires-Dist: cachetools>=3.1.0
14
15
  Requires-Dist: types-cachetools
15
16
  Requires-Dist: watchdog
@@ -21,10 +22,14 @@ Requires-Dist: types-requests
21
22
  Provides-Extra: cli
22
23
  Requires-Dist: total-perspective-vortex; extra == "cli"
23
24
  Requires-Dist: galaxy; extra == "cli"
25
+ Provides-Extra: resource-pools
26
+ Requires-Dist: redis>=5.3.0; extra == "resource-pools"
24
27
  Provides-Extra: test
25
28
  Requires-Dist: total-perspective-vortex[cli]; extra == "test"
29
+ Requires-Dist: total-perspective-vortex[resource-pools]; extra == "test"
26
30
  Requires-Dist: pytest; extra == "test"
27
31
  Requires-Dist: responses; extra == "test"
32
+ Requires-Dist: fakeredis[lua]; extra == "test"
28
33
  Requires-Dist: tox>=2.9.1; extra == "test"
29
34
  Requires-Dist: coverage>=4.4.1; extra == "test"
30
35
  Provides-Extra: dev
@@ -14,8 +14,9 @@ authors = [
14
14
  maintainers = [
15
15
  {name = "Galaxy Committers", email = "galaxy-committers@lists.galaxyproject.org"}
16
16
  ]
17
- version = "3.2.0"
17
+ version = "3.3.0"
18
18
  dependencies = [
19
+ "pydantic>=2",
19
20
  "cachetools>=3.1.0",
20
21
  "types-cachetools",
21
22
  "watchdog",
@@ -32,10 +33,19 @@ cli = [
32
33
  "total-perspective-vortex",
33
34
  "galaxy"
34
35
  ]
36
+ # Required by the Valkey-backed per-user resource pools (see docs: Per-user resource pools).
37
+ # redis-py speaks the Valkey wire protocol.
38
+ resource-pools = [
39
+ "redis>=5.3.0",
40
+ ]
35
41
  test = [
36
42
  "total-perspective-vortex[cli]",
43
+ "total-perspective-vortex[resource-pools]",
37
44
  "pytest",
38
45
  "responses",
46
+ # Lets the resource-pool store contract test run the real Valkey adapter (Lua script)
47
+ # without a live server. This is required: the production Lua path must be tested.
48
+ "fakeredis[lua]",
39
49
  "tox>=2.9.1",
40
50
  "coverage>=4.4.1",
41
51
  ]
@@ -55,6 +65,9 @@ Documentation = "https://total-perspective-vortex.readthedocs.io"
55
65
  [tool.setuptools]
56
66
  include-package-data = true
57
67
 
68
+ [tool.setuptools.packages.find]
69
+ include = ["tpv*"]
70
+
58
71
  [tool.setuptools.package-data]
59
72
  "tpv" = ["**/*.j2"]
60
73
 
@@ -0,0 +1,60 @@
1
+ import os
2
+ import unittest
3
+
4
+ from tpv.commands.dryrunner import TPVDryRunner
5
+ from tpv.commands.test import mock_galaxy
6
+ from tpv.core.resource_pool import InMemoryAllocationStore
7
+ from tpv.rules import gateway
8
+
9
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
10
+ POOL_CONFIG = os.path.join(FIXTURES, "mapping-resource-pool.yml")
11
+ DEAD_STORE_OVERLAY = os.path.join(FIXTURES, "mapping-resource-pool-dead-store.yml")
12
+ JOB_CONF = os.path.join(FIXTURES, "job_conf_dry_run.yml")
13
+
14
+
15
+ class TestMockJob(unittest.TestCase):
16
+ def test_jobs_get_distinct_default_ids(self):
17
+ # Pool admission keys the ledger by job id, so a mock job must always have one.
18
+ a, b = mock_galaxy.Job(), mock_galaxy.Job()
19
+ self.assertIsInstance(a.id, int)
20
+ self.assertNotEqual(a.id, b.id)
21
+
22
+
23
+ class TestDryRunResourcePools(unittest.TestCase):
24
+ def _run(self, tool, user, confs, explain=False):
25
+ runner = TPVDryRunner.from_params(job_conf=JOB_CONF, tool_id=tool, user_email=user, tpv_confs=confs)
26
+ return runner.run(explain=explain)
27
+
28
+ def test_dry_run_maps_a_pooled_config(self):
29
+ destination, _ = self._run("bigtool", "arthur@vortex.org", [POOL_CONFIG])
30
+ self.assertEqual(destination.id, "local")
31
+
32
+ def test_dry_run_never_touches_the_configured_store(self):
33
+ # The configured store is a Valkey nothing listens on. If the dry run consulted it, the
34
+ # job would be deferred fail-closed (or the run would error); an admin's dry run must
35
+ # neither depend on nor write to production accounting.
36
+ destination, _ = self._run("bigtool", "arthur@vortex.org", [POOL_CONFIG, DEAD_STORE_OVERLAY])
37
+ self.assertEqual(destination.id, "local")
38
+ store = gateway.ACTIVE_DESTINATION_MAPPERS["tpv_dispatcher"].resource_pools.store
39
+ self.assertIsInstance(store, InMemoryAllocationStore)
40
+
41
+ def test_dry_run_still_reports_pools_without_a_store(self):
42
+ # Swapping in an in-memory store must not hide the misconfiguration the mapper rejects.
43
+ no_store = os.path.join(FIXTURES, "mapping-resource-pool-no-store.yml")
44
+ with self.assertRaisesRegex(ValueError, "resource_pool_store"):
45
+ self._run("bigtool", "arthur@vortex.org", [no_store])
46
+
47
+ def test_explain_reports_which_pools_govern_the_job_and_why(self):
48
+ _, collector = self._run("bigtool", "arthur@vortex.org", [POOL_CONFIG], explain=True)
49
+ trace = collector.render()
50
+ self.assertIn("Resource Pools", trace)
51
+ # bigtool is 64 cores against a 32-core budget: governed by 'default', classified oversize.
52
+ self.assertIn("default", trace)
53
+ self.assertIn("oversize", trace.lower())
54
+ self.assertIn("32", trace)
55
+
56
+ def test_explain_reports_a_permanently_rejected_request(self):
57
+ # hugetool (200 cores) exceeds the oversize ceiling and can never be scheduled.
58
+ destination, collector = self._run("hugetool", "arthur@vortex.org", [POOL_CONFIG], explain=True)
59
+ self.assertIsNone(destination)
60
+ self.assertIn("can never be scheduled", collector.render())
@@ -70,7 +70,7 @@ class TestEntity(unittest.TestCase):
70
70
  # create a destination
71
71
  destination = loader.config.destinations["k8s_environment"]
72
72
  # serialize the destination
73
- serialized_destination = destination.dict()
73
+ serialized_destination = destination.model_dump()
74
74
  # deserialize the same destination
75
75
  deserialized_destination = Destination(evaluator=loader, **serialized_destination)
76
76
  # make sure the deserialized destination is the same as the original
@@ -83,7 +83,7 @@ class TestEntity(unittest.TestCase):
83
83
  # create a tool
84
84
  tool = loader.config.tools["limbo"]
85
85
  # serialize the tool
86
- serialized_tool = tool.dict()
86
+ serialized_tool = tool.model_dump()
87
87
  # deserialize the same tool
88
88
  deserialized_tool = Tool(evaluator=loader, **serialized_tool)
89
89
  # make sure the deserialized tool is the same as the original
@@ -3,11 +3,14 @@ import unittest
3
3
  from unittest.mock import MagicMock
4
4
 
5
5
  import yaml
6
+ from galaxy.jobs import JobDestination
6
7
  from galaxy.jobs.mapper import JobMappingException
7
8
 
8
9
  from tpv.commands.dryrunner import TPVDryRunner
10
+ from tpv.commands.test import mock_galaxy
9
11
  from tpv.core.entities import SchedulingTags
10
12
  from tpv.core.explain import ExplainCollector, ExplainPhase
13
+ from tpv.rules import gateway
11
14
 
12
15
 
13
16
  class TestExplainCollectorUnit(unittest.TestCase):
@@ -496,3 +499,59 @@ class TestDryRunExplain(unittest.TestCase):
496
499
  tpv_confs=[self._fixture_path("mapping-basic.yml")],
497
500
  )
498
501
  self.assertIsNone(runner.tool)
502
+
503
+
504
+
505
+ class TestGatewayExplainOnFailure(unittest.TestCase):
506
+ """Tests for the tpv_explain_on_failure gateway config option."""
507
+
508
+ @staticmethod
509
+ def _fixture_path(name):
510
+ return os.path.join(os.path.dirname(__file__), f"fixtures/{name}")
511
+
512
+ def _call_gateway(self, tool_id, referrer=None, explain_collector=None):
513
+ gateway.ACTIVE_DESTINATION_MAPPERS = {}
514
+ app = mock_galaxy.App(job_conf=self._fixture_path("job_conf.yml"))
515
+ tool = mock_galaxy.Tool(tool_id)
516
+ job = mock_galaxy.Job()
517
+ user = mock_galaxy.User("gargravarr", "fairycake@vortex.org")
518
+ return gateway.map_tool_to_destination(
519
+ app, job, tool, user,
520
+ referrer=referrer,
521
+ tpv_config_files=[self._fixture_path("mapping-basic.yml")],
522
+ explain_collector=explain_collector,
523
+ )
524
+
525
+ def test_no_log_when_param_not_set(self):
526
+ """No warning is logged on failure when tpv_explain_on_failure is not configured."""
527
+ with self.assertNoLogs("tpv.rules.gateway", level="WARNING"):
528
+ with self.assertRaises(JobMappingException):
529
+ self._call_gateway("unschedulable_tool")
530
+
531
+ def test_logs_trace_on_mapping_failure(self):
532
+ """A WARNING containing the scheduling trace is logged when tpv_explain_on_failure=true and mapping fails."""
533
+ referrer = JobDestination(id="tpv_dispatcher", params={"tpv_explain_on_failure": True})
534
+ with self.assertLogs("tpv.rules.gateway", level="WARNING") as cm:
535
+ with self.assertRaises(JobMappingException):
536
+ self._call_gateway("unschedulable_tool", referrer=referrer)
537
+ self.assertEqual(len(cm.records), 1)
538
+ trace = cm.records[0].getMessage()
539
+ self.assertIn("TPV SCHEDULING DECISION TRACE", trace)
540
+ self.assertIn("REJECTED", trace)
541
+ self.assertIn("tag mismatch", trace)
542
+ self.assertIn("No destinations", trace)
543
+
544
+ def test_no_log_on_successful_mapping(self):
545
+ """No warning is logged when tpv_explain_on_failure=true but mapping succeeds."""
546
+ referrer = JobDestination(id="tpv_dispatcher", params={"tpv_explain_on_failure": True})
547
+ with self.assertNoLogs("tpv.rules.gateway", level="WARNING"):
548
+ destination = self._call_gateway("bwa", referrer=referrer)
549
+ self.assertIsNotNone(destination)
550
+
551
+ def test_explicit_collector_not_logged_by_gateway(self):
552
+ """When an explicit explain_collector is passed (dry-run path), the gateway does not log on failure."""
553
+ collector = ExplainCollector()
554
+ with self.assertNoLogs("tpv.rules.gateway", level="WARNING"):
555
+ with self.assertRaises(JobMappingException):
556
+ self._call_gateway("unschedulable_tool", explain_collector=collector)
557
+ self.assertTrue(len(collector.steps) > 0)
@@ -0,0 +1,265 @@
1
+ """Unit tests module for the helper functions"""
2
+
3
+ import unittest
4
+ from types import SimpleNamespace
5
+ from unittest.mock import patch
6
+
7
+ from tpv.commands.test import mock_galaxy
8
+ from tpv.core.helpers import (
9
+ get_dataset_attributes,
10
+ get_input_dataset,
11
+ get_input_datasets,
12
+ get_input_size,
13
+ input_size,
14
+ weighted_choice,
15
+ weighted_random_sampling,
16
+ )
17
+
18
+
19
+ class TestHelpers(unittest.TestCase):
20
+ """Tests for helper functions"""
21
+
22
+ def test_get_dataset_attributes(self):
23
+ """Test that the function returns a dictionary with the correct attributes"""
24
+ job = mock_galaxy.Job()
25
+ dataset = mock_galaxy.Dataset("test.txt", file_size=7 * 1024**3, object_store_id="files1")
26
+ job.add_input_dataset(mock_galaxy.DatasetAssociation("test", dataset))
27
+ dataset_attributes = get_dataset_attributes(job.input_datasets)
28
+ expected_result = {dataset.id: {"object_store_id": "files1", "size": 7 * 1024**3}}
29
+ self.assertEqual(dataset_attributes, expected_result)
30
+
31
+ @staticmethod
32
+ def _job_with_multiple_data_param():
33
+ """A job with a `multiple="true"` data param `inputs` holding two datasets.
34
+
35
+ Galaxy records the first dataset twice: once as `inputs` (the copy-metadata alias for
36
+ the first element) and once as `inputs1`.
37
+ """
38
+ job = mock_galaxy.Job()
39
+ first = mock_galaxy.DatasetAssociation(
40
+ "first", mock_galaxy.Dataset("first.txt", file_size=3 * 1024**3), extension="txt"
41
+ )
42
+ second = mock_galaxy.DatasetAssociation(
43
+ "second", mock_galaxy.Dataset("second.txt", file_size=5 * 1024**3), extension="txt"
44
+ )
45
+ job.add_input_dataset(first, name="inputs")
46
+ job.add_input_dataset(first, name="inputs1")
47
+ job.add_input_dataset(second, name="inputs2")
48
+ return job
49
+
50
+ def test_get_input_datasets_deduplicates_multiple_data_param_alias(self):
51
+ """The `name`/`name1` alias of a multiple data param must not yield the dataset twice"""
52
+ job = self._job_with_multiple_data_param()
53
+ datasets = get_input_datasets(job, "inputs")
54
+ self.assertEqual([dataset.name for dataset in datasets], ["first", "second"])
55
+
56
+ def test_get_input_datasets_matches_collection_element_names(self):
57
+ """A collection param is recorded as `name1`..`nameN`, with no unsuffixed entry"""
58
+ job = mock_galaxy.Job()
59
+ for index in range(1, 4):
60
+ job.add_input_dataset(
61
+ mock_galaxy.DatasetAssociation(
62
+ f"element{index}", mock_galaxy.Dataset(f"element{index}.txt", file_size=1 * 1024**3)
63
+ ),
64
+ name=f"inputs{index}",
65
+ )
66
+ datasets = get_input_datasets(job, "inputs")
67
+ self.assertEqual([dataset.name for dataset in datasets], ["element1", "element2", "element3"])
68
+
69
+ def test_get_input_datasets_ignores_other_params(self):
70
+ """Only datasets recorded for the requested param are returned"""
71
+ job = self._job_with_multiple_data_param()
72
+ job.add_input_dataset(
73
+ mock_galaxy.DatasetAssociation("ref", mock_galaxy.Dataset("ref.txt", file_size=9 * 1024**3)),
74
+ name="reference",
75
+ )
76
+ self.assertEqual([dataset.name for dataset in get_input_datasets(job, "reference")], ["ref"])
77
+ self.assertEqual([dataset.name for dataset in get_input_datasets(job, "inputs")], ["first", "second"])
78
+ self.assertEqual([dataset.name for dataset in get_input_datasets(job)], ["first", "second", "ref"])
79
+
80
+ def test_get_input_datasets_skips_unset_optional_params(self):
81
+ """An unset optional data param is recorded with no dataset at all"""
82
+ job = mock_galaxy.Job()
83
+ job.input_datasets.append(mock_galaxy.JobToInputDatasetAssociation("inputs", None))
84
+ self.assertEqual(get_input_datasets(job, "inputs"), [])
85
+ self.assertIsNone(get_input_dataset(job, "inputs"))
86
+ self.assertEqual(get_input_size(job, "inputs"), 0)
87
+
88
+ def test_get_input_dataset_returns_first_match(self):
89
+ job = self._job_with_multiple_data_param()
90
+ dataset = get_input_dataset(job, "inputs")
91
+ self.assertEqual(dataset.name, "first")
92
+ self.assertIsNone(get_input_dataset(job, "nonexistent"))
93
+
94
+ def test_get_input_size_totals_all_inputs_by_default(self):
95
+ job = self._job_with_multiple_data_param()
96
+ self.assertEqual(get_input_size(job), 8)
97
+ # the existing input_size helper deduplicates the same way
98
+ self.assertEqual(input_size(job), 8)
99
+
100
+ def test_get_input_size_by_param_name(self):
101
+ job = self._job_with_multiple_data_param()
102
+ job.add_input_dataset(
103
+ mock_galaxy.DatasetAssociation("ref", mock_galaxy.Dataset("ref.txt", file_size=9 * 1024**3)),
104
+ name="reference",
105
+ )
106
+ self.assertEqual(get_input_size(job, "inputs"), 8)
107
+ self.assertEqual(get_input_size(job, "reference"), 9)
108
+ self.assertEqual(get_input_size(job), 17)
109
+
110
+ def test_get_input_size_adjusts_compressed_inputs(self):
111
+ job = mock_galaxy.Job()
112
+ job.add_input_dataset(
113
+ mock_galaxy.DatasetAssociation(
114
+ "compressed",
115
+ mock_galaxy.Dataset("compressed.fastq.gz", file_size=2 * 1024**3),
116
+ extension="fastqsanger.gz",
117
+ ),
118
+ name="inputs1",
119
+ )
120
+ job.add_input_dataset(
121
+ mock_galaxy.DatasetAssociation(
122
+ "uncompressed",
123
+ mock_galaxy.Dataset("uncompressed.fastq", file_size=4 * 1024**3),
124
+ extension="fastqsanger",
125
+ ),
126
+ name="inputs2",
127
+ )
128
+ self.assertAlmostEqual(get_input_size(job, "inputs"), 2 * 3.4 + 4)
129
+ self.assertAlmostEqual(get_input_size(job, "inputs", compression_factor=2), 2 * 2 + 4)
130
+ self.assertEqual(get_input_size(job, "inputs", estimate_uncompressed_size=False), 6)
131
+
132
+ def test_weighted_random_sampling_without_weights_uses_unweighted_sampling(self):
133
+ """When no destination defines params.weight, use unweighted random sampling."""
134
+ destinations = [
135
+ SimpleNamespace(id="dest_a", params={}),
136
+ SimpleNamespace(id="dest_b", params=None),
137
+ SimpleNamespace(id="dest_c", params={"foo": "bar"}),
138
+ ]
139
+ sampled_destinations = [destinations[2], destinations[0], destinations[1]]
140
+
141
+ with patch("tpv.core.helpers.random.sample", return_value=sampled_destinations) as sample_mock:
142
+ with patch("tpv.core.helpers.random.choices") as choices_mock:
143
+ result = weighted_random_sampling(destinations)
144
+
145
+ self.assertEqual(result, sampled_destinations)
146
+ sample_mock.assert_called_once_with(destinations, k=3)
147
+ choices_mock.assert_not_called()
148
+
149
+ def test_weighted_random_sampling_with_weights_uses_weighted_choices(self):
150
+ """When any destination defines params.weight, use weighted random choices."""
151
+ destinations = [
152
+ SimpleNamespace(id="dest_a", params={"weight": 5}),
153
+ SimpleNamespace(id="dest_b", params={}),
154
+ SimpleNamespace(id="dest_c", params=None),
155
+ ]
156
+ sampled_destinations = [destinations[0], destinations[0], destinations[2]]
157
+
158
+ with patch("tpv.core.helpers.random.choices", return_value=sampled_destinations) as choices_mock:
159
+ with patch("tpv.core.helpers.random.sample") as sample_mock:
160
+ result = weighted_random_sampling(destinations)
161
+
162
+ self.assertEqual(result, sampled_destinations)
163
+ choices_mock.assert_called_once_with(destinations, weights=[5, 1, 1], k=3)
164
+ sample_mock.assert_not_called()
165
+
166
+ def test_weighted_choice_without_weights_uses_unweighted_choice(self):
167
+ """When no item defines weight, use unweighted random choice."""
168
+ items = [
169
+ {"value": "/fast/jobs"},
170
+ {"value": "/slow/jobs"},
171
+ {"value": "/backup/jobs", "foo": "bar"},
172
+ ]
173
+
174
+ with patch("tpv.core.helpers.random.sample", return_value=[items[1]]) as sample_mock:
175
+ with patch("tpv.core.helpers.random.choices") as choices_mock:
176
+ result = weighted_choice(items)
177
+
178
+ self.assertEqual(result, items[1])
179
+ sample_mock.assert_called_once_with(items, k=1)
180
+ choices_mock.assert_not_called()
181
+
182
+ def test_weighted_choice_with_weights_uses_weighted_choices(self):
183
+ """When any item defines weight, use weighted random choices."""
184
+ items = [
185
+ {"value": "/fast/jobs", "weight": 3},
186
+ {"value": "/slow/jobs"},
187
+ {"value": "/backup/jobs"},
188
+ ]
189
+ with patch("tpv.core.helpers.random.choices", return_value=[items[0]]) as choices_mock:
190
+ with patch("tpv.core.helpers.random.sample") as sample_mock:
191
+ result = weighted_choice(items)
192
+
193
+ self.assertEqual(result, items[0])
194
+ choices_mock.assert_called_once_with(items, weights=[3, 1, 1], k=1)
195
+ sample_mock.assert_not_called()
196
+
197
+ def test_weighted_choice_missing_weight_defaults_to_one(self):
198
+ """Items without a weight key should default to weight 1."""
199
+ items = [
200
+ {"value": "/a", "weight": 5},
201
+ {"value": "/b"},
202
+ {"value": "/c"},
203
+ ]
204
+ with patch("tpv.core.helpers.random.choices", return_value=[items[1]]) as choices_mock:
205
+ result = weighted_choice(items)
206
+
207
+ self.assertEqual(result, items[1])
208
+ choices_mock.assert_called_once_with(items, weights=[5, 1, 1], k=1)
209
+
210
+ def test_weighted_choice_zero_weights_falls_back_to_unweighted(self):
211
+ """If all weights are zero or negative, fall back to unweighted selection."""
212
+ items = [
213
+ {"value": "/drained/jobs", "weight": 0},
214
+ {"value": "/also-drained/jobs", "weight": -1},
215
+ ]
216
+ with patch("tpv.core.helpers.random.sample", return_value=[items[0]]) as sample_mock:
217
+ with patch("tpv.core.helpers.random.choices") as choices_mock:
218
+ result = weighted_choice(items)
219
+
220
+ self.assertEqual(result, items[0])
221
+ sample_mock.assert_called_once_with(items, k=1)
222
+ choices_mock.assert_not_called()
223
+
224
+ def test_weighted_choice_negative_weight_is_clamped_to_zero(self):
225
+ """A negative weight is clamped to 0 while positive weights are preserved."""
226
+ items = [
227
+ {"value": "/a", "weight": 5},
228
+ {"value": "/b", "weight": -3},
229
+ ]
230
+ with patch("tpv.core.helpers.random.choices", return_value=[items[0]]) as choices_mock:
231
+ with patch("tpv.core.helpers.random.sample") as sample_mock:
232
+ weighted_choice(items)
233
+
234
+ choices_mock.assert_called_once_with(items, weights=[5, 0], k=1)
235
+ sample_mock.assert_not_called()
236
+
237
+ def test_weighted_choice_default_weight_keys_use_unweighted(self):
238
+ """If every item has weight: 1 (the default), use unweighted choice."""
239
+ items = [
240
+ {"value": "/a", "weight": 1},
241
+ {"value": "/b", "weight": 1},
242
+ ]
243
+ with patch("tpv.core.helpers.random.sample", return_value=[items[0]]) as sample_mock:
244
+ with patch("tpv.core.helpers.random.choices") as choices_mock:
245
+ weighted_choice(items)
246
+
247
+ sample_mock.assert_called_once_with(items, k=1)
248
+ choices_mock.assert_not_called()
249
+
250
+ def test_weighted_choice_empty_list_raises_value_error(self):
251
+ """An empty list should raise ValueError."""
252
+ with self.assertRaises(ValueError):
253
+ weighted_choice([])
254
+
255
+ def test_weighted_choice_returns_selected_item(self):
256
+ """The helper should return the selected item, not a derived string value."""
257
+ items = [
258
+ {"value": "/primary/jobs", "weight": 10},
259
+ {"value": "/secondary/jobs", "weight": 1},
260
+ ]
261
+ with patch("tpv.core.helpers.random.choices", return_value=[items[1]]):
262
+ result = weighted_choice(items)
263
+
264
+ self.assertIsInstance(result, dict)
265
+ self.assertEqual(result, items[1])
@@ -44,7 +44,7 @@ class TestMapperContext(unittest.TestCase):
44
44
  self.assertEqual(destination.params["native_spec"], "--mem 15 --cores 5 --gpus 4")
45
45
 
46
46
  def test_context_variable_overridden_in_rule(self):
47
- # test that job will not fail with 40GB input size because large_input_size has been set to 60
47
+ # test that job will not fail with 40GiB input size because large_input_size has been set to 60
48
48
  tool = mock_galaxy.Tool("bwa")
49
49
  user = mock_galaxy.User("gargravarr", "fairycake@vortex.org")
50
50
  datasets = [mock_galaxy.DatasetAssociation("test", mock_galaxy.Dataset("test.txt", file_size=40 * 1024**3))]
@@ -61,12 +61,15 @@ class TestParamsSpecific(unittest.TestCase):
61
61
  user = mock_galaxy.User("trillian", "panic@vortex.org")
62
62
 
63
63
  destination = self._map_to_destination(tool, user)
64
+ # Galaxy's resubmit state handler reads the target destination from the
65
+ # "environment" key, so TPV's user-facing "destination" key must be
66
+ # translated when building the Galaxy JobDestination.
64
67
  self.assertEqual(
65
68
  destination.resubmit,
66
69
  [
67
70
  {
68
71
  "condition": "memory_limit_reached and attempt <= 3",
69
- "destination": "tpv_dispatcher",
72
+ "environment": "tpv_dispatcher",
70
73
  "delay": "attempt * 30",
71
74
  }
72
75
  ],