From e2aecc684f54a2086f5f5e358ec82130eb1db364 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Mon, 24 Apr 2023 20:05:54 +0200 Subject: [PATCH] Option arguments as kwargs --- README.md | 52 ++++++++++++++++++++++--------- dyn2py/__init__.py | 2 +- dyn2py/files.py | 58 ++++++++++++++++++++++++++++------- dyn2py/options.py | 64 +++++++++++++++++++++++++++++---------- tests/test_CommandLine.py | 19 +++++++++++- 5 files changed, 151 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4c2453f..e3782d1 100644 --- a/README.md +++ b/README.md @@ -91,46 +91,66 @@ dyn2py --filter py --backup path/to/pythonfiles #### Git hooks -Git Hooks are a built-in feature of Git that allow developers to automate tasks throughout the Git workflow. Read more here: https://githooks.com/ +Git hooks are a built-in feature of Git that allow developers to automate tasks throughout the Git workflow. Read more here: https://githooks.com/ With the `pre-commit` hook it's possible to add more files to the currently initialized commit. -You can find an example pre-commit hook here: [pre-commit](pre-commit). Copy this file to the `.git/hooks` folder of your repo of Dynamo graph. This folder is hidden by default, but it should exist in all initialized git repo. Do not rename this file. +You can find an example pre-commit hook here: [pre-commit](pre-commit). Copy this file to the `.git/hooks` folder of your repo of Dynamo graphs. This folder is hidden by default, but it should exist in all initialized git repo. Do not rename this file. -This script will go through staged `.dyn` files and export python scripts from them, and add them to the current commit. Now you can check check changed lines in a diff tool! +This script will go through staged `.dyn` files and export python scripts from them, and add them to the current commit. Now you can check changed lines in a diff tool, you can see changed python code in a PR! ### As a python module Full API documentation available here: https://infeeeee.github.io/dyn2py -Most basic example to extract all nodes next to a Dynamo file: +#### Examples + +Extract all nodes from python nodes next to a Dynamo file: ```python import dyn2py +# Open a Dynamo graph: dynamo_file = dyn2py.DynamoFile("path/to/dynamofile.dyn") + +# Extract python nodes: dynamo_file.extract_python() + +# Save all python nodes as separate files: dyn2py.PythonFile.write_open_files() ``` -Change options like with the command line switches with the `Options` class: +Update python node from a python file: ```python import dyn2py -# Create a backup on overwrite, read python files from a different folder: -options = dyn2py.Options( - backup=True, - python_folder="path/to/pythonfiles") +# Open a python file: +python_file = dyn2py.PythonFile("path/to/pythonfile.py") +# Update the node in the graph: +python_file.update_dynamo() + +# Save modified Dynamo graph: +dyn2py.DynamoFile.write_open_files() +``` + +Update all python nodes of a Dynamo grapg from a different folder, save backups: + +```python +import dyn2py + +# open a Dynamo graph: dynamo_file = dyn2py.DynamoFile("path/to/dynamofile.dyn") -python_files = dynamo_file.get_related_python_files(options) + +# Get python files from a dofferent folder: +python_files = dynamo_file.get_related_python_files(python_folder="path/to/pythonfiles") # Read python files and update the graph: -[python_file.update_dynamo(options) for python_file in python_files] +[python_file.update_dynamo() for python_file in python_files] -# Don't forget to save at the end: -dynamo_file.write(options) +# Save open Dynamo graphs: +dyn2py.DynamoFile.write_open_files(backup=True) ``` For more examples check tests in the [tests folder on Github](https://github.com/infeeeee/dyn2py/tree/main/tests) @@ -149,6 +169,8 @@ Only supports Dynamo 2 files, Dynamo 1 files are reported and ignored. Please up Both IronPython2 and CPython3 nodes are supported! IronPython2 nodes won't be updated to CPython3, they will be imported as-is. +Cannot create new python nodes, only existing ones can be updated. + ## Development ### Installation @@ -166,7 +188,7 @@ With venv: ``` git clone https://github.com/infeeeee/dyn2py cd dyn2py -venv .venv +python -m venv .venv . ./.venv/bin/activate pip install -e . ``` @@ -204,7 +226,7 @@ python -m unittest discover -v -s ./tests -p "test_*.py" ### New release 1. Update version number in `pyproject.toml` -2. Create a publish a git tag with that number +2. Create and publish a git tag with that number ## License diff --git a/dyn2py/__init__.py b/dyn2py/__init__.py index f5ce5e3..310a4e2 100644 --- a/dyn2py/__init__.py +++ b/dyn2py/__init__.py @@ -202,6 +202,6 @@ def run(options: Options) -> None: # Write files at the end: try: - File.write_open_files() + File.write_open_files(options) except File.Error as e: logging.error(f"Cannot save file! {e.file.filepath}") diff --git a/dyn2py/files.py b/dyn2py/files.py index 42d0df5..6eb7715 100644 --- a/dyn2py/files.py +++ b/dyn2py/files.py @@ -112,21 +112,26 @@ class File(): """ return bool(self.extension == ".py") - def write(self, options: Options | None = None) -> None: + def write(self, options: Options | None = None, **option_args) -> None: """Prepare writing file to the disk: create backup, process dry-run, call filetype specific write_file() methods Should be called on subclasses! Args: options (Options | None, optional): Run options. Defaults to None. + **option_args: Options() arguments Raises: + ValueError: Both options and other arguments given TypeError: If called on a File object File.Error: Target folder does not exist """ if not options: - options = Options() + options = Options.from_kwargs(kwargs=option_args) + elif option_args: + # Should not give both options and arguments: + raise ValueError("Options object and extra arguments!") # This should only work on subclasses: if type(self).__name__ == "File": @@ -178,14 +183,22 @@ class File(): isinstance(f, cls)} @classmethod - def write_open_files(cls, options: Options | None = None) -> None: + def write_open_files(cls, options: Options | None = None, **option_args) -> None: """Write open files of this class and subclasses Args: options (Options | None, optional): Run options. Defaults to None. + **option_args: Options() arguments + + Raises: + ValueError: Both options and other arguments given + """ if not options: - options = Options() + options = Options.from_kwargs(kwargs=option_args) + elif option_args: + # Should not give both options and arguments: + raise ValueError("Options object and extra arguments!") for f in cls.get_open_files(): f.write(options) @@ -219,18 +232,25 @@ class DynamoFile(File): python_nodes: set[PythonNode] """Python node objects, read from this file.""" - def extract_python(self, options: Options | None = None) -> list[PythonFile]: + def extract_python(self, options: Options | None = None, **option_args) -> list[PythonFile]: """Extract python files from Dynamo graphs, add them to open_files Args: options (Options | None, optional): Run options. Defaults to None. + **option_args: Options() arguments + + Raises: + ValueError: Both options and other arguments given Returns: list[PythonFile]: List of PythonFile objects extracted from this DynamoFile """ if not options: - options = Options() + options = Options.from_kwargs(kwargs=option_args) + elif option_args: + # Should not give both options and arguments: + raise ValueError("Options object and extra arguments!") logging.info(f"Extracting from file: {self.filepath}") python_files = [] @@ -372,17 +392,24 @@ class DynamoFile(File): with open(self.filepath, "w", encoding="utf-8", newline="") as output_file: json.dump(self.full_dict, output_file, indent=2, use_decimal=True) - def get_related_python_files(self, options: Options | None = None) -> list[PythonFile]: + def get_related_python_files(self, options: Options | None = None, **option_args) -> list[PythonFile]: """Get python files exported from this Dynamo file Args: options (Options | None, optional): Run options. Defaults to None. + **option_args: Options() arguments + + Raises: + ValueError: Both options and other arguments given Returns: list[PythonFile]: A list of PythonFile objects """ if not options: - options = Options() + options = Options.from_kwargs(kwargs=option_args) + elif option_args: + # Should not give both options and arguments: + raise ValueError("Options object and extra arguments!") # Find the folder of the python files if options.python_folder: @@ -552,15 +579,23 @@ class PythonFile(File): logging.debug(f"Header data from python file: {self.header_data}") # logging.debug(f"Code from python file: {self.code}") - def update_dynamo(self, options: Options | None = None) -> None: + def update_dynamo(self, options: Options | None = None, **option_args) -> None: """Update a the source Dynamo graph from this python script Args: options (Options | None, optional): Run options. Defaults to None. + **option_args: Options() arguments + + Raises: + ValueError: Both options and other arguments given + """ if not options: - options = Options() + options = Options.from_kwargs(kwargs=option_args) + elif option_args: + # Should not give both options and arguments: + raise ValueError("Options object and extra arguments!") dynamo_file = self.get_source_dynamo_file() @@ -653,7 +688,7 @@ class PythonNode(): python_file (PythonFile, optional): The python file to be converted to node. Defaults to None. Raises: - Error: Wrong arguments were given + PythonNode.Error: Wrong arguments were given """ # Initialize from dynamo file: if node_dict_from_dyn and dynamo_file and not python_file: @@ -707,4 +742,5 @@ class PythonNode(): self.checksum = hashlib.md5("".join(checksums).encode()).hexdigest() class Error(Exception): + """Something wrong with this node""" pass diff --git a/dyn2py/options.py b/dyn2py/options.py index ae4355f..81c209a 100644 --- a/dyn2py/options.py +++ b/dyn2py/options.py @@ -26,16 +26,13 @@ class Options(argparse.Namespace): Args: source (list[pathlib.Path | str], optional): List of files to run on. Defaults to []. - loglevel (str, optional): log level. Defaults to DEFAULT_LOGLEVEL. - dry_run (bool, optional): If it's a dry run. Defaults to False. + loglevel (str, optional): Log level. Defaults to DEFAULT_LOGLEVEL. + dry_run (bool, optional): Dry run, do not save files. Defaults to False. force (bool, optional): Overwrite files, even if they are older. Defaults to False. backup (bool, optional): Create backup of modified files. Defaults to False. filter (str, optional): 'dyn' or 'py' file filter for running on folders. Defaults to "". update (bool, optional): Update mode, like inverse on Dynamo files. Defaults to False. python_folder (pathlib.Path | str | None, optional): Path to export python files to, or import from there. Defaults to None. - - Raises: - ValueError: If loglevel or filter is invalid """ self.source = [] @@ -45,23 +42,58 @@ class Options(argparse.Namespace): else: self.source.append(s) - if loglevel.upper() in LOGLEVELS: - self.loglevel = loglevel.upper() - else: - raise ValueError - + self.loglevel = self.sanitize_option_string("loglevel", loglevel) self.dry_run = dry_run self.force = force self.backup = backup - - if not filter or filter in FILTERS: - self.filter = filter - else: - raise ValueError - + self.filter = self.sanitize_option_string("filter", filter) self.update = update if isinstance(python_folder, str): self.python_folder = pathlib.Path(python_folder) else: self.python_folder = python_folder + + @staticmethod + def sanitize_option_string(arg: str, value: str) -> str: + """Sanitize string option values + + Args: + arg (str): The name of the argument + value (str): The value + + Raises: + ValueError: if the value is invalid + + Returns: + str: The correct string + """ + + if arg == "loglevel": + if value.upper() in LOGLEVELS: + sanitized_value = value.upper() + else: + raise ValueError("Invalid loglevel!") + elif arg == "filter": + if not value or value in FILTERS: + sanitized_value = value + else: + raise ValueError("Invalid filter!") + else: + sanitized_value = value + + return sanitized_value + + @classmethod + def from_kwargs(cls, **kwargs) -> Options: + """Initialize an Options object from kwargs + + Returns: + Options: The initialized object + """ + o = cls() + for key, value in kwargs.items(): + if isinstance(value, str): + value = cls.sanitize_option_string(key, value) + setattr(o, key, value) + return o diff --git a/tests/test_CommandLine.py b/tests/test_CommandLine.py index 2247d72..2b75e7e 100644 --- a/tests/test_CommandLine.py +++ b/tests/test_CommandLine.py @@ -46,6 +46,15 @@ class TestCommandLine(unittest.TestCase): @staticmethod def run_command(args: list = []) -> dict: + """_summary_ + + Args: + args (list, optional): list of arguments to run. Defaults to []. + + Returns: + dict["stderr"]: error message + dict["python_file_mtimes"]: exported file in the output dir, and modification times + """ argstring = " ".join(args) process = subprocess.run(f"dyn2py -l WARNING {argstring}", capture_output=True, shell=True) @@ -138,9 +147,11 @@ class TestCommandLine(unittest.TestCase): shutil.copy(f"{INPUT_DIR}/{filename}", f"{OUTPUT_DIR}/{filename}") + # Open files normally file_open = self.run_command( [s["pfolder_arg"], s['filepath']]) + # Open without error: self.assertFalse( bool(file_open["stderr"]), msg=file_open["stderr"]) self.assertEqual( @@ -150,10 +161,12 @@ class TestCommandLine(unittest.TestCase): file_no_overwrite = self.run_command( [s["pfolder_arg"], s['filepath']]) + # Should give error, because they already exist: self.assertTrue(bool(file_no_overwrite["stderr"])) self.assertEqual( len(file_no_overwrite["python_file_mtimes"]), s["py_file_count"]) + # The modify time shouldn't change as they were not overwritten: for p in file_no_overwrite["python_file_mtimes"]: self.assertEqual( file_no_overwrite["python_file_mtimes"][p], file_open["python_file_mtimes"][p]) @@ -162,10 +175,12 @@ class TestCommandLine(unittest.TestCase): file_force = self.run_command( [s["pfolder_arg"], s["force_arg"], s['filepath']]) + # Should not have an error: self.assertFalse(bool(file_force["stderr"])) self.assertEqual( len(file_force["python_file_mtimes"]), s["py_file_count"]) + #Modify time should be higher as they were replaced for p in file_force["python_file_mtimes"]: self.assertTrue( file_force["python_file_mtimes"][p] > file_open["python_file_mtimes"][p] @@ -178,7 +193,9 @@ class TestCommandLine(unittest.TestCase): self.assertFalse(bool(file_backup["stderr"])) self.assertEqual( - len(file_backup["python_file_mtimes"]), s["py_file_count"] * 2) + len(file_backup["python_file_mtimes"]), s["py_file_count"] * 2, + msg=f"" + ) for p in file_force["python_file_mtimes"]: self.assertTrue(