diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 48c52e137..cf8fa182b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,7 +10,10 @@ on: jobs: deploy: runs-on: ubuntu-latest - + name: upload release to PyPI + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write steps: - uses: actions/checkout@v2 - name: Set up Python @@ -27,4 +30,5 @@ jobs: TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel - twine upload dist/* + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/docs/changelog.md b/docs/changelog.md index 100d21d7a..9095091b3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. +## [1.7.0] -- 2024-01-26 + +### Added +- `--portable` flag to `looper report` to create a portable version of the html report +- `--lump-j` allows grouping samples into a defined number of jobs + +### Changed +- `--lumpn` is now `--lump-n` +- `--lump` is now `--lump-s` + ## [1.6.0] -- 2023-12-22 ### Added diff --git a/docs/looper-report.md b/docs/looper-report.md index c98f5aa8c..6cd4a79ea 100644 --- a/docs/looper-report.md +++ b/docs/looper-report.md @@ -6,6 +6,8 @@ Looper can create a browsable html report of all project results using the comma looper report --looper-config .your_looper_config.yaml ``` +Beginning in Looper 1.7.0, the ``--portable`` flag can be used to create a shareable, zipped version of the html report. + An example html report out put can be found here: [PEPATAC Gold Summary](https://pepatac.databio.org/en/latest/files/examples/gold/gold_summary.html) Note: pipestat must be configured by looper to perform this operation. Please see the pipestat section for more information: [Using pipestat](pipestat.md) \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md index b7a15feae..fe102ddcd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -26,7 +26,7 @@ Each task is controlled by one of the following commands: `run`, `rerun`, `runp` Here you can see the command-line usage instructions for the main looper command and for each subcommand: ## `looper --help` ```console -version: 1.6.0 +version: 1.7.0 usage: looper [-h] [--version] [--logfile LOGFILE] [--dbg] [--silent] [--verbosity V] [--logdev] [--commands] {run,rerun,runp,table,report,destroy,check,clean,inspect,init,init-piface,link} @@ -57,7 +57,7 @@ options: --silent Silence logging. Overrides verbosity. --verbosity V Set logging level (1-5 or logging module level name) --logdev Expand content of logging message format. - --commands show program's primary commands + --commands show program's version number and exit For subcommand-specific options, type: 'looper -h' https://github.com/pepkit/looper @@ -66,7 +66,7 @@ https://github.com/pepkit/looper ## `looper run --help` ```console usage: looper run [-h] [-i] [-d] [-t S] [-x S] [-y S] [-f] [--divvy DIVCFG] [-p P] [-s S] - [-c K [K ...]] [-u X] [-n N] [--looper-config LOOPER_CONFIG] + [-c K [K ...]] [-u X] [-n N] [-j J] [--looper-config LOOPER_CONFIG] [-S YAML [YAML ...]] [-P YAML [YAML ...]] [-l N] [-k N] [--sel-attr ATTR] [--sel-excl [E ...] | --sel-incl [I ...]] [--sel-flag [SELFLAG ...]] [--exc-flag [EXCFLAG ...]] [-a A [A ...]] @@ -86,8 +86,11 @@ options: -x S, --command-extra S String to append to every command -y S, --command-extra-override S Same as command-extra, but overrides values in PEP -f, --skip-file-checks Do not perform input file checks - -u X, --lump X Total input file size (GB) to batch into one job - -n N, --lumpn N Number of commands to batch into one job + -u X, --lump-s X Lump by size: total input file size (GB) to batch + into one job + -n N, --lump-n N Lump by number: number of samples to batch into one + job + -j J, --lump-j J Lump samples into number of jobs. --looper-config LOOPER_CONFIG Looper configuration file (YAML) -S YAML [YAML ...], --sample-pipeline-interfaces YAML [YAML ...] Path to looper sample config file @@ -170,10 +173,11 @@ sample selection arguments: ## `looper rerun --help` ```console usage: looper rerun [-h] [-i] [-d] [-t S] [-x S] [-y S] [-f] [--divvy DIVCFG] [-p P] - [-s S] [-c K [K ...]] [-u X] [-n N] [--looper-config LOOPER_CONFIG] - [-S YAML [YAML ...]] [-P YAML [YAML ...]] [-l N] [-k N] - [--sel-attr ATTR] [--sel-excl [E ...] | --sel-incl [I ...]] - [--sel-flag [SELFLAG ...]] [--exc-flag [EXCFLAG ...]] [-a A [A ...]] + [-s S] [-c K [K ...]] [-u X] [-n N] [-j J] + [--looper-config LOOPER_CONFIG] [-S YAML [YAML ...]] + [-P YAML [YAML ...]] [-l N] [-k N] [--sel-attr ATTR] + [--sel-excl [E ...] | --sel-incl [I ...]] [--sel-flag [SELFLAG ...]] + [--exc-flag [EXCFLAG ...]] [-a A [A ...]] [config_file] Resubmit sample jobs with failed flags. @@ -190,8 +194,11 @@ options: -x S, --command-extra S String to append to every command -y S, --command-extra-override S Same as command-extra, but overrides values in PEP -f, --skip-file-checks Do not perform input file checks - -u X, --lump X Total input file size (GB) to batch into one job - -n N, --lumpn N Number of commands to batch into one job + -u X, --lump-s X Lump by size: total input file size (GB) to batch + into one job + -n N, --lump-n N Lump by number: number of samples to batch into one + job + -j J, --lump-j J Lump samples into number of jobs. --looper-config LOOPER_CONFIG Looper configuration file (YAML) -S YAML [YAML ...], --sample-pipeline-interfaces YAML [YAML ...] Path to looper sample config file @@ -225,7 +232,7 @@ sample selection arguments: usage: looper report [-h] [--looper-config LOOPER_CONFIG] [-S YAML [YAML ...]] [-P YAML [YAML ...]] [-l N] [-k N] [--sel-attr ATTR] [--sel-excl [E ...] | --sel-incl [I ...]] [--sel-flag [SELFLAG ...]] - [--exc-flag [EXCFLAG ...]] [-a A [A ...]] [--project] + [--exc-flag [EXCFLAG ...]] [-a A [A ...]] [--project] [--portable] [config_file] Create browsable HTML report of project results. @@ -243,6 +250,7 @@ options: Path to looper project config file -a A [A ...], --amend A [A ...] List of amendments to activate --project Process project-level pipelines + --portable Makes html report portable. sample selection arguments: Specify samples to include or exclude based on sample attribute values diff --git a/looper/_version.py b/looper/_version.py index e4adfb83d..14d9d2f58 100644 --- a/looper/_version.py +++ b/looper/_version.py @@ -1 +1 @@ -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/looper/cli_looper.py b/looper/cli_looper.py index 82cb7997f..fd620a1ec 100644 --- a/looper/cli_looper.py +++ b/looper/cli_looper.py @@ -219,19 +219,27 @@ def add_subparser(cmd): for subparser in [run_subparser, rerun_subparser]: subparser.add_argument( "-u", - "--lump", + "--lump-s", default=None, metavar="X", type=html_range(min_val=0, max_val=100, step=0.1, value=0), - help="Total input file size (GB) to batch into one job", + help="Lump by size: total input file size (GB) to batch into one job", ) subparser.add_argument( "-n", - "--lumpn", + "--lump-n", default=None, metavar="N", type=html_range(min_val=1, max_val="num_samples", value=1), - help="Number of commands to batch into one job", + help="Lump by number: number of samples to batch into one job", + ) + subparser.add_argument( + "-j", + "--lump-j", + default=None, + metavar="J", + type=int, + help="Lump samples into number of jobs.", ) check_subparser.add_argument( @@ -501,6 +509,12 @@ def add_subparser(cmd): version="{}".format(" ".join(subparsers.choices.keys())), ) + report_subparser.add_argument( + "--portable", + help="Makes html report portable.", + action="store_true", + ) + result.append(parser) return result diff --git a/looper/conductor.py b/looper/conductor.py index e83616332..807d34f3e 100644 --- a/looper/conductor.py +++ b/looper/conductor.py @@ -6,6 +6,7 @@ import subprocess import time import yaml +from math import ceil from copy import copy, deepcopy from json import loads from subprocess import check_output @@ -132,6 +133,7 @@ def __init__( compute_variables=None, max_cmds=None, max_size=None, + max_jobs=None, automatic=True, collate=False, ): @@ -166,6 +168,8 @@ def __init__( include in a single job script. :param int | float | NoneType max_size: Upper bound on total file size of inputs used by the commands lumped into single job script. + :param int | float | NoneType max_jobs: Upper bound on total number of jobs to + group samples for submission. :param bool automatic: Whether the submission should be automatic once the pool reaches capacity. :param bool collate: Whether a collate job is to be submitted (runs on @@ -200,6 +204,16 @@ def __init__( "{}".format(self.extra_pipe_args) ) + if max_jobs: + if max_jobs == 0 or max_jobs < 0: + raise ValueError( + "If specified, max job command count must be a positive integer, greater than zero." + ) + + num_samples = len(self.prj.samples) + samples_per_job = num_samples / max_jobs + max_cmds = ceil(samples_per_job) + if not self.collate: self.automatic = automatic if max_cmds is None and max_size is None: diff --git a/looper/looper.py b/looper/looper.py index 32e97a0d8..51a9ee02a 100755 --- a/looper/looper.py +++ b/looper/looper.py @@ -404,8 +404,9 @@ def __call__(self, args, rerun=False, **compute_kwargs): extra_args=args.command_extra, extra_args_override=args.command_extra_override, ignore_flags=args.ignore_flags, - max_cmds=args.lumpn, - max_size=args.lump, + max_cmds=args.lump_n, + max_size=args.lump_s, + max_jobs=args.lump_j, ) submission_conductors[piface.pipe_iface_file] = conductor @@ -547,12 +548,16 @@ def __call__(self, args): p = self.prj project_level = args.project + portable = args.portable + if project_level: psms = self.prj.get_pipestat_managers(project_level=True) print(psms) for name, psm in psms.items(): # Summarize will generate the static HTML Report Function - report_directory = psm.summarize(looper_samples=self.prj.samples) + report_directory = psm.summarize( + looper_samples=self.prj.samples, portable=portable + ) print(f"Report directory: {report_directory}") else: for piface_source_samples in self.prj._samples_by_piface( @@ -567,7 +572,9 @@ def __call__(self, args): print(psms) for name, psm in psms.items(): # Summarize will generate the static HTML Report Function - report_directory = psm.summarize(looper_samples=self.prj.samples) + report_directory = psm.summarize( + looper_samples=self.prj.samples, portable=portable + ) print(f"Report directory: {report_directory}") diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index a811c95dd..0f3963a5e 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -6,7 +6,7 @@ logmuse>=0.2.0 pandas>=2.0.2 pephubclient>=0.1.2 peppy>=0.40.0 -pipestat>=0.6.0 +pipestat>=0.8.0 pyyaml>=3.12 rich>=9.10.0 ubiquerg>=0.5.2 diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 85ccc6e46..f02a8bc9d 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -1,4 +1,4 @@ -hypothesis +hypothesis >= 6.84.3 mock pytest pytest-cov diff --git a/tests/smoketests/test_run.py b/tests/smoketests/test_run.py index c646103fc..5d166fe38 100644 --- a/tests/smoketests/test_run.py +++ b/tests/smoketests/test_run.py @@ -439,7 +439,7 @@ def test_looper_run_produces_submission_scripts(self, prep_temp_pep): def test_looper_lumping(self, prep_temp_pep): tp = prep_temp_pep - x = test_args_expansion(tp, "run", ["--lumpn", "2"]) + x = test_args_expansion(tp, "run", ["--lump-n", "2"]) try: main(test_args=x) except Exception: @@ -447,6 +447,23 @@ def test_looper_lumping(self, prep_temp_pep): sd = os.path.join(get_outdir(tp), "submission") verify_filecount_in_dir(sd, ".sub", 4) + def test_looper_lumping_jobs(self, prep_temp_pep): + tp = prep_temp_pep + x = test_args_expansion(tp, "run", ["--lump-j", "1"]) + try: + main(test_args=x) + except Exception: + raise pytest.fail("DID RAISE {0}".format(Exception)) + sd = os.path.join(get_outdir(tp), "submission") + verify_filecount_in_dir(sd, ".sub", 2) + + def test_looper_lumping_jobs_negative(self, prep_temp_pep): + tp = prep_temp_pep + x = test_args_expansion(tp, "run", ["--lump-j", "-1"]) + + with pytest.raises(ValueError): + main(test_args=x) + def test_looper_limiting(self, prep_temp_pep): tp = prep_temp_pep x = test_args_expansion(tp, "run", ["--limit", "2"]) diff --git a/tests/test_natural_range.py b/tests/test_natural_range.py index 662d674cf..76f899539 100644 --- a/tests/test_natural_range.py +++ b/tests/test_natural_range.py @@ -2,7 +2,7 @@ from typing import * import pytest -from hypothesis import Phase, given, settings, strategies as st +from hypothesis import given, strategies as st from looper.utils import NatIntervalException, NatIntervalInclusive @@ -49,148 +49,158 @@ def test_upper_less_than_lower__fails_as_expected(self, bounds): NatIntervalInclusive(lo, hi) -@pytest.mark.skip(reason="Unable to reproduce test failing locally.") class NaturalRangeFromStringTests: """Tests for parsing of natural number range from text, like CLI arg""" - @pytest.mark.parametrize( - "arg_template", ["0{sep}0", "{sep}0", "0{sep}", "0{sep}0", "{sep}0", "0{sep}"] - ) - @given(upper_bound=gen_pos_int) - def test_zero__does_not_parse(self, arg_template, legit_delim, upper_bound): - arg = arg_template.format(sep=legit_delim) - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) - @given(upper_bound=st.integers()) - def test_just_delimiter__does_not_parse(self, legit_delim, upper_bound): - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(legit_delim, upper_bound=upper_bound) +@pytest.mark.parametrize( + "arg_template", ["0{sep}0", "{sep}0", "0{sep}", "0{sep}0", "{sep}0", "0{sep}"] +) +@given(upper_bound=gen_pos_int) +def test_from_string__zero__does_not_parse(arg_template, legit_delim, upper_bound): + arg = arg_template.format(sep=legit_delim) + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) - @given( - lo_hi_upper=st.tuples(gen_opt_int, gen_opt_int, st.integers()).filter( - lambda t: (t[0] is not None or t[1] is not None) - and any(is_non_pos(n) for n in t) - ) - ) - def test_nonpositive_values__fail_with_expected_error( - self, lo_hi_upper, legit_delim - ): - lo, hi, upper_bound = lo_hi_upper - if lo is None and hi is None: - raise ValueError("Both lower and upper bound generated are null.") - if lo is None: - arg = legit_delim + str(hi) - elif hi is None: - arg = str(lo) + legit_delim - else: - arg = str(lo) + legit_delim + str(hi) - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) - @pytest.mark.parametrize("arg", ["1,2", "1;2", "1_2", "1/2", "1.2", "1~2"]) - @given(upper_bound=st.integers(min_value=3)) - def test_illegal_delimiter__fail_with_expected_error(self, arg, upper_bound): - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) +@given(upper_bound=st.integers()) +def test_from_string__just_delimiter__does_not_parse(legit_delim, upper_bound): + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(legit_delim, upper_bound=upper_bound) + - @given( - lower_and_limit=st.tuples(st.integers(), st.integers()).filter( - lambda p: p[1] < p[0] - ) +@given( + lo_hi_upper=st.tuples(gen_opt_int, gen_opt_int, st.integers()).filter( + lambda t: (t[0] is not None or t[1] is not None) + and any(is_non_pos(n) for n in t) ) - def test_one_sided_lower_with_samples_lt_bound__fails( - self, lower_and_limit, legit_delim - ): - lower, limit = lower_and_limit - arg = str(lower) + legit_delim - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(arg, upper_bound=limit) - - @given(lower_and_upper=nondecreasing_pair_strategy(min_value=1)) - def test_one_sided_lower_with_samples_gteq_bound__succeeds( - self, lower_and_upper, legit_delim - ): - lo, upper_bound = lower_and_upper - exp = NatIntervalInclusive(lo, upper_bound) +) +def test_from_string__nonpositive_values__fail_with_expected_error( + lo_hi_upper, legit_delim +): + lo, hi, upper_bound = lo_hi_upper + if lo is None and hi is None: + raise ValueError("Both lower and upper bound generated are null.") + if lo is None: + arg = legit_delim + str(hi) + elif hi is None: arg = str(lo) + legit_delim - obs = NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) - assert obs == exp - - @given(upper_and_limit=nondecreasing_pair_strategy(min_value=1)) - def test_one_sided_upper_with_samples_gteq_bound__succeeds( - self, upper_and_limit, legit_delim - ): - upper, limit = upper_and_limit - exp = NatIntervalInclusive(1, upper) - arg = legit_delim + str(upper) - obs = NatIntervalInclusive.from_string(arg, upper_bound=limit) - assert obs == exp - - @given( - upper_and_limit=st.tuples( - st.integers(min_value=1), st.integers(min_value=1) - ).filter(lambda p: p[1] < p[0]) - ) - def test_one_sided_upper_with_samples_lt_bound__uses_bound( - self, upper_and_limit, legit_delim - ): - upper, limit = upper_and_limit - exp = NatIntervalInclusive(1, limit) - arg = legit_delim + str(upper) - obs = NatIntervalInclusive.from_string(arg, upper_bound=limit) - assert obs == exp - - @given( - lower_upper_limit=st.tuples(gen_pos_int, gen_pos_int, gen_pos_int).filter( - lambda t: t[1] < t[0] or t[2] < t[0] - ) - ) - def test_two_sided_parse_upper_lt_lower(self, lower_upper_limit, legit_delim): - lo, hi, lim = lower_upper_limit + else: arg = str(lo) + legit_delim + str(hi) - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(arg, upper_bound=lim) + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) + + +@pytest.mark.parametrize("arg", ["1,2", "1;2", "1_2", "1/2", "1.2", "1~2"]) +@given(upper_bound=st.integers(min_value=3)) +def test_from_string__illegal_delimiter__fail_with_expected_error(arg, upper_bound): + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) - @given( - lo_hi_limit=st.tuples( - st.integers(min_value=2), gen_pos_int, gen_pos_int - ).filter(lambda t: t[2] < t[0] <= t[1]) - ) - def test_two_sided_parse_upper_gteq_lower_with_upper_limit_lt_lower( - self, lo_hi_limit, legit_delim - ): - lo, hi, limit = lo_hi_limit - arg = str(lo) + legit_delim + str(hi) - with pytest.raises(NatIntervalException): - NatIntervalInclusive.from_string(arg, upper_bound=limit) - @given( - lo_hi_limit=st.tuples(gen_pos_int, gen_pos_int, gen_pos_int).filter( - lambda t: t[0] < t[2] < t[1] - ) +@given( + lower_and_limit=st.tuples(st.integers(), st.integers()).filter( + lambda p: p[1] < p[0] ) - def test_two_sided_parse_upper_gteq_lower_with_upper_limit_between_lower_and_upper( - self, - lo_hi_limit, - legit_delim, - ): - lo, hi, limit = lo_hi_limit - exp = NatIntervalInclusive(lo, limit) - arg = str(lo) + legit_delim + str(hi) - obs = NatIntervalInclusive.from_string(arg, upper_bound=limit) - assert obs == exp +) +def test_from_string__one_sided_lower_with_samples_lt_bound__fails( + lower_and_limit, legit_delim +): + lower, limit = lower_and_limit + arg = str(lower) + legit_delim + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(arg, upper_bound=limit) + + +@given(lower_and_upper=nondecreasing_pair_strategy(min_value=1)) +def test_from_string__one_sided_lower_with_samples_gteq_bound__succeeds( + lower_and_upper, legit_delim +): + lo, upper_bound = lower_and_upper + exp = NatIntervalInclusive(lo, upper_bound) + arg = str(lo) + legit_delim + obs = NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) + assert obs == exp + + +@given(upper_and_limit=nondecreasing_pair_strategy(min_value=1)) +def test_from_string__one_sided_upper_with_samples_gteq_bound__succeeds( + upper_and_limit, legit_delim +): + upper, limit = upper_and_limit + exp = NatIntervalInclusive(1, upper) + arg = legit_delim + str(upper) + obs = NatIntervalInclusive.from_string(arg, upper_bound=limit) + assert obs == exp + + +@given( + upper_and_limit=st.tuples( + st.integers(min_value=1), st.integers(min_value=1) + ).filter(lambda p: p[1] < p[0]) +) +def test_from_string__one_sided_upper_with_samples_lt_bound__uses_bound( + upper_and_limit, legit_delim +): + upper, limit = upper_and_limit + exp = NatIntervalInclusive(1, limit) + arg = legit_delim + str(upper) + obs = NatIntervalInclusive.from_string(arg, upper_bound=limit) + assert obs == exp + + +@given( + lower_upper_limit=st.tuples(gen_pos_int, gen_pos_int, gen_pos_int).filter( + lambda t: t[1] < t[0] or t[2] < t[0] + ) +) +def test_from_string__two_sided_parse_upper_lt_lower(lower_upper_limit, legit_delim): + lo, hi, lim = lower_upper_limit + arg = str(lo) + legit_delim + str(hi) + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(arg, upper_bound=lim) + - @given( - lo_hi_upper=st.tuples(gen_pos_int, gen_pos_int, gen_pos_int).filter( - lambda t: t[0] <= t[1] <= t[2] - ) +@given( + lo_hi_limit=st.tuples(st.integers(min_value=2), gen_pos_int, gen_pos_int).filter( + lambda t: t[2] < t[0] <= t[1] + ) +) +def test_from_string__two_sided_parse_upper_gteq_lower_with_upper_limit_lt_lower( + lo_hi_limit, legit_delim +): + lo, hi, limit = lo_hi_limit + arg = str(lo) + legit_delim + str(hi) + with pytest.raises(NatIntervalException): + NatIntervalInclusive.from_string(arg, upper_bound=limit) + + +@given( + lo_hi_limit=st.tuples(gen_pos_int, gen_pos_int, gen_pos_int).filter( + lambda t: t[0] < t[2] < t[1] + ) +) +def test_from_string__two_sided_parse_upper_gteq_lower_with_upper_limit_between_lower_and_upper( + lo_hi_limit, + legit_delim, +): + lo, hi, limit = lo_hi_limit + exp = NatIntervalInclusive(lo, limit) + arg = str(lo) + legit_delim + str(hi) + obs = NatIntervalInclusive.from_string(arg, upper_bound=limit) + assert obs == exp + + +@given( + lo_hi_upper=st.tuples(gen_pos_int, gen_pos_int, gen_pos_int).filter( + lambda t: t[0] <= t[1] <= t[2] ) - def test_two_sided_parse_upper_gteq_lower_with_upper_limit_gteq_upper( - self, lo_hi_upper, legit_delim - ): - lo, hi, upper_bound = lo_hi_upper - exp = NatIntervalInclusive(lo, hi) - arg = f"{str(lo)}{legit_delim}{str(hi)}" - obs = NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) - assert obs == exp +) +def test_from_string__two_sided_parse_upper_gteq_lower_with_upper_limit_gteq_upper( + lo_hi_upper, legit_delim +): + lo, hi, upper_bound = lo_hi_upper + exp = NatIntervalInclusive(lo, hi) + arg = f"{str(lo)}{legit_delim}{str(hi)}" + obs = NatIntervalInclusive.from_string(arg, upper_bound=upper_bound) + assert obs == exp