From 76e8a9cd98c80a2775f1ceede20e499f6a3f77c1 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 30 Mar 2022 17:17:26 +0200 Subject: [PATCH 1/4] add rule filtering by tags --- organize/cli.py | 37 +++++++++++++++++++++++++++++++------ organize/config.py | 1 + organize/core.py | 33 ++++++++++++++++++++++++++++++--- organize/filters/created.py | 5 +---- tests/core/test_tags.py | 10 ++++++++++ 5 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 tests/core/test_tags.py diff --git a/organize/cli.py b/organize/cli.py index 0b07f164..35540011 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -76,18 +76,27 @@ def list_commands(self, ctx): hidden=True, type=click.Path(exists=True, dir_okay=False, path_type=Path), ) +CLI_TAGS = click.option("--tags", default="") +CLI_SKIP_TAGS = click.option("--skip-tags", default="") -def run_local(config_path: Path, working_dir: Path, simulate: bool): +def run_local(config_path: Path, working_dir: Path, simulate: bool, tags, skip_tags): from schema import SchemaError from . import core try: + tag_list = [tag.strip() for tag in tags.split(",") if tag] + skip_tag_list = [tag.strip() for tag in skip_tags.split(",") if tag] console.info(config_path=config_path, working_dir=working_dir) config = config_path.read_text() os.chdir(working_dir) - core.run(rules=config, simulate=simulate) + core.run( + rules=config, + simulate=simulate, + tags=tag_list, + skip_tags=skip_tag_list, + ) except NeedsMigrationError as e: console.error(e, title="Config needs migration") console.warn( @@ -122,28 +131,44 @@ def cli(): @CLI_CONFIG @CLI_WORKING_DIR_OPTION @CLI_CONFIG_FILE_OPTION -def run(config: Path, working_dir: Path, config_file): +@CLI_TAGS +@CLI_SKIP_TAGS +def run(config: Path, working_dir: Path, config_file, tags, skip_tags): """Organizes your files according to your rules.""" if config_file: config = config_file console.deprecated( "The --config-file option can now be omitted. See organize --help." ) - run_local(config_path=config, working_dir=working_dir, simulate=False) + run_local( + config_path=config, + working_dir=working_dir, + simulate=False, + tags=tags, + skip_tags=skip_tags, + ) @cli.command() @CLI_CONFIG @CLI_WORKING_DIR_OPTION @CLI_CONFIG_FILE_OPTION -def sim(config: Path, working_dir: Path, config_file): +@CLI_TAGS +@CLI_SKIP_TAGS +def sim(config: Path, working_dir: Path, config_file, tags, skip_tags): """Simulates a run (does not touch your files).""" if config_file: config = config_file console.deprecated( "The --config-file option can now be omitted. See organize --help." ) - run_local(config_path=config, working_dir=working_dir, simulate=True) + run_local( + config_path=config, + working_dir=working_dir, + simulate=True, + tags=tags, + skip_tags=skip_tags, + ) @cli.command() diff --git a/organize/config.py b/organize/config.py index 223e917c..8a36743f 100644 --- a/organize/config.py +++ b/organize/config.py @@ -19,6 +19,7 @@ Optional("filter_mode", description="The filter mode."): Or( "all", "any", "none", error="Invalid filter mode" ), + Optional("tags"): Or(str, [str]), Optional( "targets", description="Whether the rule should apply to directories or folders.", diff --git a/organize/core.py b/organize/core.py index 63023a47..6eb4fa76 100644 --- a/organize/core.py +++ b/organize/core.py @@ -239,7 +239,21 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo return True -def run_rules(rules: dict, simulate: bool = True): +def should_execute(rule_tags, tags, skip_tags): + if "always" in rule_tags and "always" not in skip_tags: + return True + if "never" in rule_tags and "never" not in tags: + return False + if not tags and not skip_tags: + return True + if not rule_tags and tags: + return False + should_run = any(tag in tags for tag in rule_tags) + should_skip = any(tag in skip_tags for tag in rule_tags) + return should_run and not should_skip + + +def run_rules(rules: dict, tags, skip_tags, simulate: bool = True): count = Counter(done=0, fail=0) # type: Counter if simulate: @@ -247,6 +261,13 @@ def run_rules(rules: dict, simulate: bool = True): console.spinner(simulate=simulate) for rule_nr, rule in enumerate(rules["rules"], start=1): + should_run = should_execute( + rule_tags=rule.get("tags", []), + tags=tags, + skip_tags=skip_tags, + ) + if not should_run: + continue target = rule.get("targets", "files") console.rule(rule.get("name", "Rule %s" % rule_nr)) filter_mode = rule.get("filter_mode", "all") @@ -304,7 +325,13 @@ def run_rules(rules: dict, simulate: bool = True): return count -def run(rules: Union[str, dict], simulate: bool, validate=True): +def run( + rules: Union[str, dict], + simulate: bool, + tags, + skip_tags, + validate=True, +): # load and validate if isinstance(rules, str): rules = config.load_from_string(rules) @@ -322,7 +349,7 @@ def run(rules: Union[str, dict], simulate: bool, validate=True): console.warn(msg) # run - count = run_rules(rules=rules, simulate=simulate) + count = run_rules(rules=rules, tags=tags, skip_tags=skip_tags, simulate=simulate) console.summary(count) if count["fail"]: diff --git a/organize/filters/created.py b/organize/filters/created.py index 5d0e78ea..dadc2360 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -56,7 +56,4 @@ def fallback_method(self, fs, fs_path): pass def __str__(self): - return "[Created] All files / folders %s than %s" % ( - self._mode, - self.timedelta, - ) + return "" diff --git a/tests/core/test_tags.py b/tests/core/test_tags.py new file mode 100644 index 00000000..dfdf297a --- /dev/null +++ b/tests/core/test_tags.py @@ -0,0 +1,10 @@ +from organize.core import should_execute + + +def test_tags(): + assert should_execute(rule_tags=[], tags=[], skip_tags=[]) + assert should_execute(rule_tags=["anything"], tags=[], skip_tags=[]) + assert should_execute(rule_tags=["always"], tags=["debug", "test"], skip_tags=[]) + assert not should_execute(rule_tags=[], tags=["debug", "test"], skip_tags=[]) + assert not should_execute(rule_tags=["test"], tags=["debug"], skip_tags=["test"]) + assert not should_execute(rule_tags=["never"], tags=[], skip_tags=[]) From abd6cc90bd3f0012fb1194e25d52b77ce67e19c3 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 31 Mar 2022 10:59:17 +0200 Subject: [PATCH 2/4] add tests --- organize/cli.py | 30 ++++++++++++++++++++------- organize/core.py | 9 +++++++- tests/core/test_tags.py | 46 ++++++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 35540011..46967754 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -5,10 +5,11 @@ """ import os import sys +from pathlib import Path +from typing import Tuple import click from fs import appfs -from pathlib import Path from . import console from .__version__ import __version__ @@ -57,6 +58,15 @@ def list_commands(self, ctx): return self.commands.keys() +class TagType(click.ParamType): + name = "tag" + + def convert(self, value, param, ctx): + if not value: + return tuple() + return tuple(value.split(",")) + + CLI_CONFIG = click.argument( "config", required=False, @@ -76,26 +86,30 @@ def list_commands(self, ctx): hidden=True, type=click.Path(exists=True, dir_okay=False, path_type=Path), ) -CLI_TAGS = click.option("--tags", default="") -CLI_SKIP_TAGS = click.option("--skip-tags", default="") +CLI_TAGS = click.option("--tags", type=TagType(), default="") +CLI_SKIP_TAGS = click.option("--skip-tags", type=TagType(), default="") -def run_local(config_path: Path, working_dir: Path, simulate: bool, tags, skip_tags): +def run_local( + config_path: Path, + working_dir: Path, + simulate: bool, + tags: Tuple[str] = tuple(), + skip_tags: Tuple[str] = tuple(), +): from schema import SchemaError from . import core try: - tag_list = [tag.strip() for tag in tags.split(",") if tag] - skip_tag_list = [tag.strip() for tag in skip_tags.split(",") if tag] console.info(config_path=config_path, working_dir=working_dir) config = config_path.read_text() os.chdir(working_dir) core.run( rules=config, simulate=simulate, - tags=tag_list, - skip_tags=skip_tag_list, + tags=tags, + skip_tags=skip_tags, ) except NeedsMigrationError as e: console.error(e, title="Config needs migration") diff --git a/organize/core.py b/organize/core.py index 6eb4fa76..44516bba 100644 --- a/organize/core.py +++ b/organize/core.py @@ -240,6 +240,13 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo def should_execute(rule_tags, tags, skip_tags): + if not rule_tags: + rule_tags = set() + if not tags: + tags = set() + if not skip_tags: + skip_tags = set() + if "always" in rule_tags and "always" not in skip_tags: return True if "never" in rule_tags and "never" not in tags: @@ -248,7 +255,7 @@ def should_execute(rule_tags, tags, skip_tags): return True if not rule_tags and tags: return False - should_run = any(tag in tags for tag in rule_tags) + should_run = any(tag in tags for tag in rule_tags) or not rule_tags should_skip = any(tag in skip_tags for tag in rule_tags) return should_run and not should_skip diff --git a/tests/core/test_tags.py b/tests/core/test_tags.py index dfdf297a..57e4cd5c 100644 --- a/tests/core/test_tags.py +++ b/tests/core/test_tags.py @@ -1,10 +1,42 @@ from organize.core import should_execute -def test_tags(): - assert should_execute(rule_tags=[], tags=[], skip_tags=[]) - assert should_execute(rule_tags=["anything"], tags=[], skip_tags=[]) - assert should_execute(rule_tags=["always"], tags=["debug", "test"], skip_tags=[]) - assert not should_execute(rule_tags=[], tags=["debug", "test"], skip_tags=[]) - assert not should_execute(rule_tags=["test"], tags=["debug"], skip_tags=["test"]) - assert not should_execute(rule_tags=["never"], tags=[], skip_tags=[]) +def test_no_tags_given(): + assert should_execute(rule_tags=None, tags=None, skip_tags=None) + assert should_execute(rule_tags=["tag"], tags=None, skip_tags=None) + assert should_execute(rule_tags=["tag", "tag2"], tags=None, skip_tags=None) + + +def test_run_tagged(): + assert not should_execute(rule_tags=None, tags=["tag"], skip_tags=None) + assert should_execute(rule_tags=["tag"], tags=["tag"], skip_tags=None) + assert should_execute(rule_tags=["tag", "tag2"], tags=["tag"], skip_tags=None) + assert not should_execute(rule_tags=["foo", "bar"], tags=["tag"], skip_tags=None) + assert not should_execute(rule_tags=["taggity"], tags=["tag"], skip_tags=None) + + +def test_skip(): + assert should_execute(rule_tags=None, tags=None, skip_tags=["tag"]) + assert not should_execute(rule_tags=["tag"], tags=None, skip_tags=["tag"]) + assert not should_execute(rule_tags=["tag", "tag2"], tags=None, skip_tags=["tag"]) + + +def test_combination(): + assert not should_execute(rule_tags=None, tags=["tag"], skip_tags=["foo"]) + assert not should_execute(rule_tags=["foo", "tag"], tags=["tag"], skip_tags=["foo"]) + + +def test_always(): + assert should_execute(rule_tags=["always"], tags=["debug", "test"], skip_tags=None) + assert should_execute(rule_tags=["always", "tag"], tags=None, skip_tags=["tag"]) + # skip only if specifically requested + assert not should_execute( + rule_tags=["always", "tag"], tags=None, skip_tags=["always"] + ) + + +def test_never(): + assert not should_execute(rule_tags=["never"], tags=None, skip_tags=None) + assert not should_execute(rule_tags=["never", "tag"], tags=["tag"], skip_tags=None) + # run only if specifically requested + assert should_execute(rule_tags=["never", "tag"], tags=["never"], skip_tags=None) From 53e55068ef33817792eba6ed274e2e2c026ddea4 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 31 Mar 2022 11:09:24 +0200 Subject: [PATCH 3/4] fix testcase --- organize/core.py | 6 +++--- tests/core/test_tags.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/organize/core.py b/organize/core.py index 44516bba..479efd66 100644 --- a/organize/core.py +++ b/organize/core.py @@ -255,7 +255,7 @@ def should_execute(rule_tags, tags, skip_tags): return True if not rule_tags and tags: return False - should_run = any(tag in tags for tag in rule_tags) or not rule_tags + should_run = any(tag in tags for tag in rule_tags) or not tags or not rule_tags should_skip = any(tag in skip_tags for tag in rule_tags) return should_run and not should_skip @@ -335,9 +335,9 @@ def run_rules(rules: dict, tags, skip_tags, simulate: bool = True): def run( rules: Union[str, dict], simulate: bool, - tags, - skip_tags, validate=True, + tags=None, + skip_tags=None, ): # load and validate if isinstance(rules, str): diff --git a/tests/core/test_tags.py b/tests/core/test_tags.py index 57e4cd5c..bbc0a4ba 100644 --- a/tests/core/test_tags.py +++ b/tests/core/test_tags.py @@ -17,6 +17,7 @@ def test_run_tagged(): def test_skip(): assert should_execute(rule_tags=None, tags=None, skip_tags=["tag"]) + assert should_execute(rule_tags=["tag"], tags=None, skip_tags=["asd"]) assert not should_execute(rule_tags=["tag"], tags=None, skip_tags=["tag"]) assert not should_execute(rule_tags=["tag", "tag2"], tags=None, skip_tags=["tag"]) From 7bb374c9e41dee8c645df6cd1145e9b86dc63e8a Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 31 Mar 2022 11:32:22 +0200 Subject: [PATCH 4/4] support comma-separated string tags in config --- CHANGELOG.md | 4 ++++ docs/configuration.md | 28 ++++++++++++++++++++++++++++ docs/rules.md | 2 ++ organize/cli.py | 2 +- organize/core.py | 5 ++++- 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc1e317..61dd216a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## WIP + +- Tag support (#199) to run subsets of rules in your config. + ## v2.1.2 (2022-02-13) - Hotfix for `filecontent` filter. diff --git a/docs/configuration.md b/docs/configuration.md index d85c2073..e3dcaa06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,34 @@ Optionally you can specify the working directory like this: organize sim [FILE] --working-dir=~/Documents ``` +## Running specific rules of your config + +You can tag your rules like this: + +```yml +rules: + - name: My first rule + actions: + - echo: "Hello world" + tags: + - debug + - fast +``` + +Then use the command line options `--tags` and `--skip-tags` so select the rules you +want to run. The options take a comma-separated list of tags: + +``` +organize sim --tags=debug,foo --skip-tags=slow +``` + +Special tags: + +- Rules tagged with the special tag `always` will always run + (except if `--skip-tags=always` is specified) +- Rules tagged with the special tag `never` will never run + (except if ' `--tags=never` is specified) + ## Environment variables - `ORGANIZE_CONFIG` - The path to the default config file. diff --git a/docs/rules.md b/docs/rules.md index 607ba92d..f86de8cc 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -34,6 +34,7 @@ rules: filter_mode: ... filters: ... actions: ... + tags: ... # Another rule - name: ... @@ -51,6 +52,7 @@ The rule options in detail: - **filter_mode** (`str`): `"all"`, `"any"` or `"none"` of the filters must apply _(Default: `"all"`)_ - **filters** (`list`): A list of [filters](filters.md) _(Default: `[]`)_ - **actions** (`list`): A list of [actions](actions.md) +- **tags** (`list`): A list of [tags](configuration.md#running-specific-rules-of-your-config) ## Targeting directories diff --git a/organize/cli.py b/organize/cli.py index 46967754..faf9b512 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -64,7 +64,7 @@ class TagType(click.ParamType): def convert(self, value, param, ctx): if not value: return tuple() - return tuple(value.split(",")) + return tuple(tag.strip() for tag in value.split(",")) CLI_CONFIG = click.argument( diff --git a/organize/core.py b/organize/core.py index 479efd66..287ee446 100644 --- a/organize/core.py +++ b/organize/core.py @@ -268,8 +268,11 @@ def run_rules(rules: dict, tags, skip_tags, simulate: bool = True): console.spinner(simulate=simulate) for rule_nr, rule in enumerate(rules["rules"], start=1): + rule_tags = rule.get("tags") + if isinstance(rule_tags, str): + rule_tags = [tag.strip() for tag in rule_tags.split(",")] should_run = should_execute( - rule_tags=rule.get("tags", []), + rule_tags=rule_tags, tags=tags, skip_tags=skip_tags, )