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.
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/PKG-INFO +6 -1
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/pyproject.toml +14 -1
- total_perspective_vortex-3.3.0/tests/test_dryrunner_resource_pool.py +60 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_entity.py +2 -2
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_explain.py +59 -0
- total_perspective_vortex-3.3.0/tests/test_helpers.py +265 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_context.py +1 -1
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_params_specific.py +4 -1
- total_perspective_vortex-3.3.0/tests/test_mapper_resource_pool_integration.py +300 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_resource_requirements.py +14 -5
- total_perspective_vortex-3.3.0/tests/test_mapper_weighted_choice.py +59 -0
- total_perspective_vortex-3.3.0/tests/test_resource_pool.py +760 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_scenarios.py +2 -2
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_shell.py +56 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/PKG-INFO +6 -1
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/SOURCES.txt +5 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/requires.txt +6 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/dryrunner.py +18 -4
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/dumper.py +2 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/linter.py +1 -1
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/mypychecker.py +1 -2
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/shell.py +9 -4
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/test/mock_galaxy.py +19 -8
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/entities.py +94 -17
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/explain.py +1 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/helpers.py +119 -9
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/loader.py +5 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/mapper.py +202 -9
- total_perspective_vortex-3.3.0/tpv/core/resource_pool.py +393 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/resource_requirements.py +6 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/rules/gateway.py +27 -14
- total_perspective_vortex-3.2.0/tests/test_helpers.py +0 -59
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/LICENSE +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/README.md +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/setup.cfg +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/setup.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_dump.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_basic.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_destinations.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_inheritance.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_merge_multiple.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_rank.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_resubmit.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_role.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_rules.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_sample.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_user.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/dependency_links.txt +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/entry_points.txt +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/total_perspective_vortex.egg-info/top_level.txt +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/__init__.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/__init__.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/formatter.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/test/__init__.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/commands/type_check_template.j2 +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/__init__.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/evaluator.py +0 -0
- {total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tpv/core/util.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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])
|
{total_perspective_vortex-3.2.0 → total_perspective_vortex-3.3.0}/tests/test_mapper_context.py
RENAMED
|
@@ -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
|
|
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
|
-
"
|
|
72
|
+
"environment": "tpv_dispatcher",
|
|
70
73
|
"delay": "attempt * 30",
|
|
71
74
|
}
|
|
72
75
|
],
|