diff --git a/README.md b/README.md index d3aa1da..eba4306 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ python -m rrparser -o rules.csv python -m rrparser --release 3.1.0 --dataset rhea --diameters 0-8 -o rules.csv # Equivalent selection using radii. -python -m rrparser --dataset rhea --radii 0,1,2,3,4 -o rules.csv +python -m rrparser --dataset rhea --radii 0-4 -o rules.csv # Aggregate datasets within one release (comma or + separator). python -m rrparser --release 3.1.0 --dataset metanetx,rhea -d 0-8 -o rules.csv @@ -106,14 +106,17 @@ python -m rrparser --dataset rhea --diameters '<=8' -o rules.csv python -m rrparser --dataset rhea --diameters '>=8' -o rules.csv ``` -Alternatively, `--radii` accepts comma-separated integers from 0 to 10: -`--radii 0,1,2` is equivalent to `--diameters 0,2,4`. The two options are -mutually exclusive. Without either option, all diameters 0,2,…,20 are selected. +`--radii` supports the same expressions over integer radii 0–10: +`0,1,2`, `0-4`, `<4`, `<=4`, `>4`, `>=4`, and combinations such as `0-2,>=8`. +For example, `--radii '<=4'` is equivalent to `--diameters '<=8'`. +The two options are mutually exclusive. Both are expanded into normalized +integer radii by the argument parser, before calling `parse_rules()`. +Without either option, all radii 0–10 (diameters 0,2,…,20) are selected. For templates, a row is retained when at least one source radius matches `diameter / 2`, once per template. The output `RADII` column retains all source radii, separated by semicolons. Explicit odd diameters or values above 20 are -rejected for templates. +rejected before parsing, including for rr02. rr02 supplies diameters 2,4,…,16. The extended default therefore selects all its rules. A selection containing no available diameter, such as `-d 20`, @@ -158,7 +161,7 @@ archive cache. | `--list-releases` | Print available release/dataset pairs and exit. | | `--cache-dir`, `--rules-dir` | Source cache directory; default package-local `rrparser/cache`. | | `-d`, `--diameters` | List, range, or comparison; default all even diameters 0–20. | -| `--radii` | Comma-separated radii 0–10, as an alternative to `--diameters`. | +| `--radii` | List, range, or comparison over radii 0–10, as an alternative to `--diameters`. | | `-rt`, `--rule-type` | `all` (default), `retro`, or `forward`; templates require `all`. | | `-ec`, `--ec` | File of comma-separated EC prefixes to keep. | | `-ecx`, `--ecx` | File of comma-separated EC prefixes to exclude; mutually exclusive with `--ec`. | @@ -176,7 +179,7 @@ Run `python -m rrparser --help` for the full CLI help. Use keyword arguments to select the source, filters and output: ```python -from rrparser import list_retrorules, parse_rules +from rrparser import list_retrorules, parse_diameters, parse_radii, parse_rules # Mapping: (release, dataset) -> download URL. choices = list_retrorules() @@ -186,14 +189,14 @@ parse_rules( outfile="rules.csv", release="3.1.0", dataset="rhea", - diameters="<=8", + radii=parse_radii("<=4"), ) # Return CSV text instead of writing a file. csv_text = parse_rules( outfile=None, dataset="rhea", - radii="0,1,2", + radii=[0, 1, 2], ) # Aggregate datasets; dataset="all" is also supported. @@ -201,25 +204,34 @@ parse_rules( outfile="combined.csv", release="3.1.0", dataset="metanetx+rhea", - diameters="<=8", + radii=parse_diameters("<=8"), ) # Parse a local file with EC filtering and replacement scores. parse_rules( outfile="filtered.csv", rules_file="input.csv", - diameters="2,4", + radii=[1, 2], ec=["1.1"], scores_file="scores.csv", ) ``` `rules_dir=` overrides the cache directory. `release=`, `dataset=`, and cache -lookup apply when `rules_file='retrorules'`, the default. Diameter expressions -have the same meaning in Python and on the CLI. Pass `radii=` as an alternative -to `diameters=`. `ec=` and `ecx=` take lists of prefixes; `read_ecnumbers()` +lookup apply when `rules_file='retrorules'`, the default. + +`parse_rules()` accepts only normalized integer radii via `radii=` (a list or +tuple, default 0–10); it no longer accepts `diameters=` or expression strings. +Use the exported `parse_radii(expression)` to expand radius expressions, or +`parse_diameters(expression)` to expand diameter expressions and convert them +to radii before calling `parse_rules()`. Existing Python callers using +`diameters=expression` should migrate to `radii=parse_diameters(expression)`; +callers using `radii=expression` should use `radii=parse_radii(expression)`. +`ec=` and `ecx=` take lists of prefixes; `read_ecnumbers()` can load these from a comma-separated file. Invalid selections raise -`ValueError`; the CLI reports these errors and exits with status 1. +`ValueError`; invalid radius sequence types raise `TypeError`. Invalid CLI +expressions are rejected by the argument parser with status 2 before any +download; runtime selection errors are reported with status 1. The CLI argument parser is also available: @@ -228,6 +240,7 @@ from rrparser import build_args_parser parser = build_args_parser() params = parser.parse_args(["--dataset", "rhea", "--diameters", "<=8"]) +assert params.radii == [0, 1, 2, 3, 4] # No separate diameters attribute. ``` ## Output compatibility diff --git a/rrparser/Args.py b/rrparser/Args.py index 3a719f3..431912a 100644 --- a/rrparser/Args.py +++ b/rrparser/Args.py @@ -8,13 +8,60 @@ from argparse import ArgumentParser from os import path as os_path +from re import fullmatch from rrparser._version import __version__ DEFAULT_RULES_DIR = os_path.join(os_path.dirname(os_path.realpath(__file__)), "cache") DEFAULT_RULES_FILE = "retrorules" DEFAULT_RELEASE = "3.1.0" -DEFAULT_DIAMETERS = "0,2,4,6,8,10,12,14,16,18,20" +DEFAULT_RADII = tuple(range(11)) + + +def _parse_sizes(expression, maximum, step): + values = set() + for term in expression.split(","): + term = term.strip() + interval = fullmatch(r"(\d+)\s*-\s*(\d+)", term) + comparison = fullmatch(r"([<>])(=?)\s*(\d+)", term) + if term.isdigit(): + value = int(term) + if value > maximum or value % step: + raise ValueError( + f"Values must be multiples of {step} from 0 to {maximum}" + ) + values.add(value) + elif interval: + lower, upper = map(int, interval.groups()) + if lower > upper: + raise ValueError("Range must have its lower bound first") + values.update( + value + for value in range(0, maximum + 1, step) + if lower <= value <= upper + ) + elif comparison: + operator, inclusive, bound = comparison.groups() + bound = int(bound) + values.update( + value + for value in range(0, maximum + 1, step) + if (value < bound if operator == "<" else value > bound) + or (inclusive and value == bound) + ) + else: + raise ValueError("Use a list (0,2,4), range (0-8), <8, <=8, >8 or >=8") + return sorted(values) + + +def parse_radii(expression): + """Expand a radius expression into integer radii 0–10.""" + return _parse_sizes(expression, 10, 1) + + +def parse_diameters(expression): + """Expand a diameter expression and convert it to integer radii.""" + return [diameter // 2 for diameter in _parse_sizes(expression, 20, 2)] def build_args_parser(): @@ -87,16 +134,18 @@ def _add_arguments(parser): sizes.add_argument( "-d", "--diameters", - type=str, - default=DEFAULT_DIAMETERS, + type=parse_diameters, + dest="radii", + default=DEFAULT_RADII, help='Diameters: list (0,2,4), inclusive range (0-8), "<8", "<=8", ">8" or ">=8". ' "Ranges and comparisons select even values from 0 to 20 (default: all). " "For templates, diameter = 2 * radius; rr02 provides diameters 2 to 16", ) sizes.add_argument( "--radii", - type=str, - help="Comma-separated radii from 0 to 10 (alternative to --diameters)", + type=parse_radii, + help='Radii: list (0,1,2), inclusive range (0-4), "<4", "<=4", ">4" or ">=4". ' + "Values range from 0 to 10 (default: all); mutually exclusive with --diameters.", ) parser.add_argument( "-of", diff --git a/rrparser/Parser.py b/rrparser/Parser.py index d92ab98..1b228a3 100644 --- a/rrparser/Parser.py +++ b/rrparser/Parser.py @@ -8,15 +8,16 @@ from __future__ import annotations +from collections.abc import Sequence from csv import QUOTE_ALL, QUOTE_NONE from logging import Logger, getLogger -from re import fullmatch, split +from re import split from tarfile import open as open_tar from pandas import DataFrame, concat, read_csv from .Args import ( - DEFAULT_DIAMETERS, + DEFAULT_RADII, DEFAULT_RELEASE, DEFAULT_RULES_DIR, DEFAULT_RULES_FILE, @@ -133,17 +134,16 @@ def parse_rules( rules_dir: str = DEFAULT_RULES_DIR, input_format: str = "csv", rule_type: str = "all", - diameters: str = DEFAULT_DIAMETERS, + radii: Sequence[int] = DEFAULT_RADII, ecx: list[str] | None = None, ec: list[str] | None = None, output_format: str = "csv", logger: Logger = LOGGER, release: str = DEFAULT_RELEASE, dataset: str = "metanetx", - radii: str | None = None, ) -> str | None: """ - Parse a reaction rules file and extract sub-part according 'diameters' and 'rule_type' filters. + Parse reaction rules using normalized radii and rule usage filters. Parameters ---------- @@ -159,8 +159,8 @@ def parse_rules( Input format of rules_file ['csv', 'tsv'] (default 'csv') rule_type: str Type of reaction rules ['all', 'forward', 'retro'] (default 'all') - diameters: str - Diameters to filter (default: all even values from 0 to 20). + radii: Sequence[int] + Integer radii to keep (default 0–10). Expressions are expanded before this call. ecx: List[str] List of EC numbers to remove from rules ec: List[str] @@ -173,8 +173,6 @@ def parse_rules( RetroRules version (default 3.1.0), rr02, or latest. dataset: str Dataset name, comma/plus-separated names, or all (default metanetx). - radii: str - Comma-separated radii 0–10, as an alternative to diameters. Returns ------- @@ -182,17 +180,7 @@ def parse_rules( """ - # Check args is here since the method is directly callable. - if radii is not None: - if diameters != DEFAULT_DIAMETERS: - raise ValueError("Use either radii or diameters") - values = radii.split(",") - if any(not value.isdigit() or int(value) > 10 for value in values): - raise ValueError("Radii must be integers from 0 to 10, separated by commas") - diameters = ",".join(str(2 * int(value)) for value in values) - diameters, sep, quoting = check_args( - rule_type, diameters, input_format, output_format - ) + radii, sep, quoting = check_args(rule_type, radii, input_format, output_format) rules_files = [rules_file] if rules_file == "retrorules": @@ -209,7 +197,7 @@ def parse_rules( frames = [_read_rules(filename, **read_options) for filename in rules_files] rf = _merge_templates(frames) if len(frames) > 1 else frames[0] - # Filter rules according to 'rule_type' and 'diameters' + # Filter rules according to rule usage and normalized radii. templates = "TEMPLATE_ID" in rf.columns if templates: rf = rf.rename( @@ -220,7 +208,7 @@ def parse_rules( "SCORE": "Score", } ) - results = filter_(rf, rule_type, diameters, ecx, ec, logger=logger) + results = filter_(rf, rule_type, radii, ecx, ec, logger=logger) # If 'rules_scores_file' is set, then read it and merge with 'results' if scores_file: @@ -278,13 +266,13 @@ def replace_scores(results, scores, logger: Logger = LOGGER): def filter_( df: DataFrame, rule_type: str, - diameters: list[int], + radii: list[int], ecx: list[str] | None = None, ec: list[str] | None = None, logger: Logger = LOGGER, ) -> DataFrame: """ - Filter a pandas dataframe with 'Diameters' and 'Rule usage' criteria. + Filter a pandas dataframe using radii and rule usage. Parameters ---------- @@ -292,8 +280,8 @@ def filter_( Pandas dataframe rule_type: str Type of reaction rules ['all', 'forward', 'retro'] - diameters: List[int] - Diameters to filter + radii: List[int] + Radii to filter ecx: List[str] List of EC numbers to filter out from rules ec: List[str] @@ -304,9 +292,9 @@ def filter_( Returns ------- query: Pandas dataframe - Original dataframe filtered according to 'rule_type' and 'diameters'. + Original dataframe filtered by rule usage and radii. """ - logger.debug(f"Args: {df}, {rule_type}, {diameters}") + logger.debug(f"Args: {df}, {rule_type}, {radii}") ecx = ecx if ecx is not None else [] ec = ec if ec is not None else [] @@ -314,7 +302,7 @@ def filter_( filter = "in" if ec else "not in" logger.info("Filtering rules:") logger.info(f" -> EC numbers ({filter}): {ec_filter}") - logger.info(f" -> Diameters: {diameters}") + logger.info(f" -> Radii: {radii}") logger.info(f" -> Rule types: {rule_type}") # Identify rules that contain at least one EC number starting by a pattern contained in 'ecx' @@ -324,19 +312,14 @@ def filter_( query = f"index {filter} @rules_idx" query_vars = { "rules_idx": rules_idx, - "diameters": diameters, + "radii": radii, "rule_usage_filter": ["both", rule_type], } if "RADII" in df.columns: - if any(diameter % 2 or diameter > 20 for diameter in diameters): - raise ValueError( - "Template diameters must be even values from 0 to 20 (radii 0 to 10)" - ) if rule_type != "all": raise ValueError( "RetroRules templates have no retro/forward annotation; use --rule-type all" ) - radii = {diameter // 2 for diameter in diameters} radius_idx = df[ df["RADII"] .fillna("") @@ -350,6 +333,7 @@ def filter_( query_vars["radius_idx"] = radius_idx query += " & index in @radius_idx" else: + query_vars["diameters"] = [2 * radius for radius in radii] query += " & Diameter == @diameters" if rule_type != "all": query += " & `Rule usage` == @rule_usage_filter" @@ -434,39 +418,9 @@ def fetch_retrorules( return fetch_dataset(rules_dir, release, dataset) -def parse_diameters(expression: str) -> list[int]: - """Expand lists, inclusive ranges and comparisons into diameters.""" - diameters = set() - for term in expression.split(","): - term = term.strip() - interval = fullmatch(r"(\d+)\s*-\s*(\d+)", term) - comparison = fullmatch(r"([<>])(=?)\s*(\d+)", term) - if term.isdigit(): - diameters.add(int(term)) - elif interval: - lower, upper = map(int, interval.groups()) - if lower > upper: - raise ValueError("Diameter range must have its lower bound first") - diameters.update(d for d in range(0, 21, 2) if lower <= d <= upper) - elif comparison: - operator, inclusive, bound = comparison.groups() - bound = int(bound) - diameters.update( - d - for d in range(0, 21, 2) - if (d < bound if operator == "<" else d > bound) - or (inclusive and d == bound) - ) - else: - raise ValueError( - "Invalid --diameters: use a list (0,2,4), range (0-8), <8, <=8, >8 or >=8" - ) - return sorted(diameters) - - def check_args( rule_type: str, - diameters: str, + radii: Sequence[int], input_format: str, output_format: str, logger: Logger = LOGGER, @@ -478,8 +432,8 @@ def check_args( ---------- rule_type: str Type of reaction rules ['all', 'forward', 'retro'] (default 'all') - diameters: str - Diameters to filter (default: all even values from 0 to 20). + radii: Sequence[int] + Integer radii to keep (default 0–10). Expressions are expanded before this call. input_format: str Input format of rules_file ['csv', 'tsv'] (default 'csv') output_format: str @@ -489,19 +443,24 @@ def check_args( Returns ------- - diameters, sep, quoting: Tuple[List[int], str, str] - diameters - diameters converted into list of int. + radii, sep, quoting: Tuple[List[int], str, int] + radii - validated integer radii. sep - separation character detected from 'input_format'. quoting - quoting behavior detected from 'output_format'. """ - logger.debug(f"Args: {rule_type}, {diameters}, {input_format}, {output_format}") + logger.debug(f"Args: {rule_type}, {radii}, {input_format}, {output_format}") # Rule type if not rule_type or rule_type not in ["all", "retro", "forward"]: raise ValueError("Cannot detect 'rule_type' input: " + str(rule_type)) - # Diameter - diameters = parse_diameters(diameters) + if isinstance(radii, (str, bytes)) or not isinstance(radii, Sequence): + raise TypeError( + "radii must be a sequence of integers from 0 to 10; expand expressions with parse_radii()" + ) + if any(type(radius) is not int or not 0 <= radius <= 10 for radius in radii): + raise ValueError("Radii must be integers from 0 to 10") + radii = sorted(set(radii)) # Input format if input_format == "csv": @@ -519,4 +478,4 @@ def check_args( else: raise ValueError("Can only have 'csv' or 'tsv' input formats") - return diameters, sep, quoting + return radii, sep, quoting diff --git a/rrparser/__init__.py b/rrparser/__init__.py index 25abe83..3f287ca 100644 --- a/rrparser/__init__.py +++ b/rrparser/__init__.py @@ -6,7 +6,7 @@ """ from rrparser._version import __version__ -from rrparser.Args import build_args_parser +from rrparser.Args import build_args_parser, parse_diameters, parse_radii from rrparser.Downloads import list_retrorules from rrparser.Parser import parse_rules, read_ecnumbers @@ -14,6 +14,8 @@ "__version__", "build_args_parser", "list_retrorules", + "parse_diameters", + "parse_radii", "parse_rules", "read_ecnumbers", ] diff --git a/rrparser/__main__.py b/rrparser/__main__.py index 2da2f46..efa707d 100644 --- a/rrparser/__main__.py +++ b/rrparser/__main__.py @@ -51,7 +51,6 @@ def entry_point(): outfile=args.outfile, input_format=args.input_format, rule_type=args.rule_type, - diameters=args.diameters, ecx=ecx, ec=ec, output_format=args.output_format, diff --git a/tests/test_aggregation.py b/tests/test_aggregation.py index 628583c..74eb23c 100644 --- a/tests/test_aggregation.py +++ b/tests/test_aggregation.py @@ -6,6 +6,7 @@ import pytest from rrparser import build_args_parser, parse_rules +from rrparser.Args import parse_radii from rrparser.Downloads import fetch_datasets @@ -61,7 +62,11 @@ def test_cached_datasets_are_merged_before_filtering(tmp_path, selection): ): output = parse_rules(None, rules_dir=str(tmp_path), dataset=args.dataset) filtered = parse_rules( - None, rules_dir=str(tmp_path), dataset=selection, radii="10", ec=["1.1"] + None, + rules_dir=str(tmp_path), + dataset=selection, + radii=parse_radii("10"), + ec=["1.1"], ) frame = pd.read_csv(StringIO(output)) assert set(frame["Rule ID"]) == {"shared", "mnx", "rhea"} @@ -145,7 +150,7 @@ def test_merged_score_replacement_and_missing_annotations(tmp_path): None, rules_dir=str(tmp_path), dataset="rhea,uspto", - radii="10", + radii=parse_radii("10"), scores_file=str(scores), ) shared = pd.read_csv(StringIO(result)).iloc[0] diff --git a/tests/test_diameter_expressions.py b/tests/test_diameter_expressions.py index 42b5265..4dc1b56 100644 --- a/tests/test_diameter_expressions.py +++ b/tests/test_diameter_expressions.py @@ -5,7 +5,7 @@ import pytest from rrparser import build_args_parser, parse_rules -from rrparser.Parser import parse_diameters +from rrparser.Args import parse_diameters @pytest.mark.parametrize( @@ -33,7 +33,7 @@ ], ) def test_expressions(expression, expected, tmp_path): - assert parse_diameters(expression) == expected + assert parse_diameters(expression) == [diameter // 2 for diameter in expected] args = build_args_parser().parse_args(["--diameters", expression]) rules = tmp_path / "templates.csv" pd.DataFrame( @@ -45,7 +45,7 @@ def test_expressions(expression, expected, tmp_path): "RADII": list(range(11)), } ).to_csv(rules, index=False) - output = parse_rules(None, rules_file=str(rules), diameters=args.diameters) + output = parse_rules(None, rules_file=str(rules), radii=args.radii) assert pd.read_csv(StringIO(output))["Rule ID"].tolist() == [ f"RR:{diameter // 2}" for diameter in expected ] @@ -71,6 +71,6 @@ def test_invalid_expressions(expression): ) def test_legacy_rules(expression, enumeration): rules = str(Path(__file__).parent / "data/rules.csv") - assert parse_rules(None, rules_file=rules, diameters=expression) == parse_rules( - None, rules_file=rules, diameters=enumeration - ) + assert parse_rules( + None, rules_file=rules, radii=parse_diameters(expression) + ) == parse_rules(None, rules_file=rules, radii=parse_diameters(enumeration)) diff --git a/tests/test_diameters.py b/tests/test_diameters.py index 63859a1..4708015 100644 --- a/tests/test_diameters.py +++ b/tests/test_diameters.py @@ -3,22 +3,22 @@ from test_main import Test_RR +from rrparser.Args import parse_diameters from rrparser.Parser import parse_rules class Test_RR_Diameters(Test_RR): def test_BadDiametersArgument(self): - output = parse_rules( - None, rules_file=self.rules_file, rule_type="retro", diameters="3" - ) - self.assertEqual(len(output.splitlines()), 1) + self.assertRaises(ValueError, parse_diameters, "3") def test_OneDiameter(self): # A closed path can be reopened by pandas on Windows. with TemporaryDirectory() as directory: outfile = Path(directory) / "rules.csv" - parse_rules(str(outfile), rules_file=self.rules_file, diameters="2") + parse_rules( + str(outfile), rules_file=self.rules_file, radii=parse_diameters("2") + ) self.assertEqual(outfile.read_text(), Path(self.ref_d2_csv).read_text()) def test_MiscDiametersArgument(self): - self.assertRaises(ValueError, parse_rules, None, diameters="2-") + self.assertRaises(ValueError, parse_diameters, "2-") diff --git a/tests/test_downloads.py b/tests/test_downloads.py index f04c42d..0f74b3c 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -10,7 +10,7 @@ import pytest from rrparser import build_args_parser, list_retrorules, parse_rules -from rrparser.Args import DEFAULT_RULES_DIR +from rrparser.Args import DEFAULT_RULES_DIR, parse_diameters, parse_radii from rrparser.Downloads import RR02_CACHE_FILE, RR02_FILE, RR02_URL, fetch_dataset PAGE = b"""CSV @@ -121,7 +121,7 @@ def test_rr02_download_cache_and_parsing(): rules_dir=directory, release="rr02", rule_type="retro", - diameters="2", + radii=parse_diameters("2"), ) parsed = pd.read_csv(StringIO(output)) assert not parsed.empty @@ -166,7 +166,7 @@ def test_templates_filters_scores_and_output(): output = parse_rules( None, rules_file=str(rules), - diameters="8", + radii=parse_diameters("8"), ec=["1.1"], scores_file=str(scores), output_format="tsv", @@ -178,7 +178,11 @@ def test_templates_filters_scores_and_output(): assert ( len( pd.read_csv( - StringIO(parse_rules(None, rules_file=str(rules), diameters="20")) + StringIO( + parse_rules( + None, rules_file=str(rules), radii=parse_diameters("20") + ) + ) ) ) == 1 @@ -190,7 +194,7 @@ def test_templates_filters_scores_and_output(): def test_default_release_ignores_latest_cache(): args = build_args_parser().parse_args([]) assert args.release == "3.1.0" - assert args.diameters == ",".join(str(diameter) for diameter in range(0, 21, 2)) + assert list(args.radii) == list(range(11)) with TemporaryDirectory() as directory: cache = Path(directory) rules = cache / "retrorules-3.1.0-metanetx.csv.gz" @@ -220,11 +224,15 @@ def test_all_template_diameters(): output = parse_rules(None, rules_file=str(rules)) assert len(pd.read_csv(StringIO(output))) == 11 for diameter in range(0, 21, 2): - output = parse_rules(None, rules_file=str(rules), diameters=str(diameter)) + output = parse_rules( + None, rules_file=str(rules), radii=parse_diameters(str(diameter)) + ) assert pd.read_csv(StringIO(output))["Rule ID"].tolist() == [ f"RR:{diameter // 2}" ] - output = parse_rules(None, rules_file=str(rules), diameters="0,18,20") + output = parse_rules( + None, rules_file=str(rules), radii=parse_diameters("0,18,20") + ) assert pd.read_csv(StringIO(output))["Rule ID"].tolist() == [ "RR:0", "RR:9", @@ -232,7 +240,7 @@ def test_all_template_diameters(): ] for invalid in ["1", "3", "19", "21", "22", "-2"]: with pytest.raises(ValueError): - parse_rules(None, rules_file=str(rules), diameters=invalid) + parse_rules(None, rules_file=str(rules), radii=parse_diameters(invalid)) def test_cache_and_filtered_output_are_separate(): @@ -272,11 +280,16 @@ def test_cache_and_filtered_output_are_separate(): with patch( "rrparser.Downloads.urlopen", side_effect=AssertionError("Network used") ): - parse_rules(str(output), rules_dir=str(cache), dataset="rhea", radii="10") + parse_rules( + str(output), + rules_dir=str(cache), + dataset="rhea", + radii=parse_radii("10"), + ) assert pd.read_csv(output)["Rule ID"].tolist() == ["RR:2"] assert gzip.decompress(source.read_bytes()) == CSV assert not (cache / "rules.csv").exists() assert not (Path(directory) / source.name).exists() for invalid in ["-1", "11", "1.5", ""]: - with pytest.raises(ValueError, match="Radii must"): + with pytest.raises(TypeError, match="radii must"): parse_rules(None, rules_file=str(source), radii=invalid) diff --git a/tests/test_filters.py b/tests/test_filters.py index e0b24ed..b7c3556 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -34,7 +34,7 @@ def test_filter_by_rule_type(self): df = filter_( df=self.df, rule_type=rule_type, - diameters=[2, 4, 6, 8, 10, 12, 14, 16], + radii=[diameter // 2 for diameter in [2, 4, 6, 8, 10, 12, 14, 16]], ecx=[], ) # reindex df @@ -51,7 +51,12 @@ def test_filter_by_diameters(self): f"rules_d_{'-'.join(str(x) for x in diameters)}.csv", ) ) - df = filter_(df=self.df, rule_type="all", diameters=diameters, ecx=[]) + df = filter_( + df=self.df, + rule_type="all", + radii=[diameter // 2 for diameter in diameters], + ecx=[], + ) # reindex df df = df.reset_index(drop=True) self.assertTrue(df.equals(df_expected)) @@ -60,7 +65,10 @@ def test_filter_by_ecx(self): ecx = read_ecnumbers(path.join(self.here, "data", "ec.csv")) df_expected = read_csv(path.join(self.here, "data", "rules_ecx.csv")) df = filter_( - df=self.df, rule_type="all", diameters=[2, 4, 6, 8, 10, 12, 14, 16], ecx=ecx + df=self.df, + rule_type="all", + radii=[diameter // 2 for diameter in [2, 4, 6, 8, 10, 12, 14, 16]], + ecx=ecx, ) # reindex df df = df.reset_index(drop=True) diff --git a/tests/test_input-format.py b/tests/test_input-format.py index e108741..b5ca234 100644 --- a/tests/test_input-format.py +++ b/tests/test_input-format.py @@ -1,6 +1,7 @@ from test_main import Test_RR from rrparser import parse_rules +from rrparser.Args import parse_diameters class Test_RR_InputFormat(Test_RR): @@ -11,7 +12,7 @@ def test_BadInputFormatCSV_1(self): None, rules_file=self.rules_file, input_format="tsv", - diameters="2", + radii=parse_diameters("2"), ) def test_BadInputFormatCSV_2(self): @@ -21,5 +22,5 @@ def test_BadInputFormatCSV_2(self): None, rules_file=self.rules_file, input_format="other", - diameters="2", + radii=parse_diameters("2"), ) diff --git a/tests/test_light.py b/tests/test_light.py index a3fe904..e532578 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -4,6 +4,7 @@ from test_main import Test_RR from rrparser import parse_rules +from rrparser.Args import parse_diameters class Test_RR_Light(Test_RR): @@ -15,7 +16,7 @@ def test_GoodInputFormatCSV(self): str(outfile), rules_file=self.rules_file, input_format="csv", - diameters="2", + radii=parse_diameters("2"), ) self.assertEqual(outfile.read_text(), Path(self.ref_d2_csv).read_text()) @@ -26,7 +27,7 @@ def test_BadInputFormatCSV_1(self): None, rules_file=self.rules_file, input_format="tsv", - diameters="2", + radii=parse_diameters("2"), ) def test_BadInputFormatCSV_2(self): @@ -36,5 +37,5 @@ def test_BadInputFormatCSV_2(self): None, rules_file=self.rules_file, input_format="other", - diameters="2", + radii=parse_diameters("2"), ) diff --git a/tests/test_misc.py b/tests/test_misc.py index 38335f4..ee0bffe 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -18,6 +18,7 @@ from test_main import Test_RR from rrparser import parse_rules +from rrparser.Args import parse_diameters from rrparser.Downloads import RR02_CACHE_FILE, RR02_FILE @@ -33,7 +34,7 @@ def test_SmallRulesFile_OneDiameter_SpecifyOutfile(self): parse_rules( str(outfile), rules_file=self.rules_file, - diameters="2", + radii=parse_diameters("2"), output_format=format, ) reference = Path(getattr(self, "ref_d2_" + format)) @@ -57,7 +58,7 @@ def test_AllTypes_CachedRR02(self): rules_dir=str(cache), release="rr02", rule_type=rule_type, - diameters=",".join(map(str, diameters)), + radii=parse_diameters(",".join(map(str, diameters))), ) results = read_csv(outfile) self.assertFalse(results.empty) diff --git a/tests/test_radii.py b/tests/test_radii.py new file mode 100644 index 0000000..a41b8e2 --- /dev/null +++ b/tests/test_radii.py @@ -0,0 +1,110 @@ +import logging +from io import StringIO + +import pandas as pd +import pytest + +from rrparser import build_args_parser, parse_diameters, parse_radii, parse_rules + + +@pytest.mark.parametrize( + "expression,expected", + [ + ("0,2,4", [0, 2, 4]), + ("0-8", list(range(9))), + ("<8", list(range(8))), + ("<=8", list(range(9))), + (">8", [9, 10]), + (">=8", [8, 9, 10]), + ("0-2,8,>=10", [0, 1, 2, 8, 10]), + (" 1 - 3 , 3 ", [1, 2, 3]), + ("<0", []), + (">10", []), + (">=10", [10]), + ("<=10", list(range(11))), + ], +) +def test_radius_expressions_select_templates(tmp_path, expression, expected): + assert parse_radii(expression) == expected + args = build_args_parser().parse_args(["--radii", expression]) + assert args.radii == expected + assert not hasattr(args, "diameters") + source = tmp_path / "templates.csv" + pd.DataFrame( + { + "TEMPLATE_ID": [f"RR:{radius}" for radius in range(11)], + "TEMPLATE": ["[C]>>[O]"] * 11, + "SCORE": [1.0] * 11, + "ECS": ["1.1.1.1"] * 11, + "RADII": list(range(11)), + } + ).to_csv(source, index=False) + result = parse_rules(None, rules_file=str(source), radii=args.radii) + assert pd.read_csv(StringIO(result))["Rule ID"].tolist() == [ + f"RR:{radius}" for radius in expected + ] + + +@pytest.mark.parametrize( + "option,expression,expected", + [ + ("--radii", "0-4", [0, 1, 2, 3, 4]), + ("--radii", "<=4", [0, 1, 2, 3, 4]), + ("--diameters", "0-8", [0, 1, 2, 3, 4]), + ("-d", "<=8", [0, 1, 2, 3, 4]), + ("--radii", ">=8", [8, 9, 10]), + ("--diameters", ">=16", [8, 9, 10]), + ], +) +def test_cli_only_passes_normalized_radii(monkeypatch, option, expression, expected): + from unittest.mock import Mock + + from rrparser import __main__ as cli + + parse = Mock(return_value=None) + monkeypatch.setattr(cli, "parse_rules", parse) + monkeypatch.setattr(cli, "create_logger", lambda *args: logging.getLogger("test")) + monkeypatch.setattr("sys.argv", ["rrparser", option, expression, "-o", "rules.csv"]) + cli.entry_point() + kwargs = parse.call_args[1] + assert kwargs["radii"] == expected + assert "diameters" not in kwargs + + +@pytest.mark.parametrize( + "option,expression", + [ + ("--radii", "11"), + ("--radii", "2-"), + ("--radii", "4-0"), + ("--radii", "1.5"), + ("--radii", "-1"), + ("--radii", ""), + ("--diameters", "3"), + ("--diameters", "22"), + ], +) +def test_invalid_expressions_fail_before_parsing(monkeypatch, option, expression): + from unittest.mock import Mock + + from rrparser import __main__ as cli + + parse = Mock() + monkeypatch.setattr(cli, "parse_rules", parse) + monkeypatch.setattr("sys.argv", ["rrparser", option, expression]) + with pytest.raises(SystemExit) as error: + cli.entry_point() + assert error.value.code == 2 + parse.assert_not_called() + + +@pytest.mark.parametrize("radii", [[-1], [11], [1.5], [True]]) +def test_api_validates_normalized_radii_before_download(radii): + with pytest.raises(ValueError, match="Radii must be integers"): + parse_rules(None, radii=radii) + + +def test_defaults_and_empty_selection(): + args = build_args_parser().parse_args([]) + assert list(args.radii) == list(range(11)) + assert parse_diameters("<0") == parse_radii("<0") == [] diff --git a/tests/test_rule-type.py b/tests/test_rule-type.py index 4c9aca8..306d99a 100644 --- a/tests/test_rule-type.py +++ b/tests/test_rule-type.py @@ -1,5 +1,6 @@ from test_main import Test_RR +from rrparser.Args import parse_diameters from rrparser.Parser import parse_rules @@ -12,5 +13,5 @@ def test_BadRuleTypeArgument(self): parse_rules, None, rule_type=rule_type, - diameters="2", + radii=parse_diameters("2"), )