2 Commits

Author SHA1 Message Date
8909594a98 Update Tests 2023-04-25 00:39:24 +02:00
e2aecc684f Option arguments as kwargs 2023-04-24 20:05:54 +02:00
9 changed files with 234 additions and 71 deletions

View File

@@ -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

View File

@@ -7,6 +7,7 @@
- [x] DynamoFile
- [x] PythonFile
- [x] PythonNode
- [ ] Options
- [ ] run()
## CI/CD

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -3,15 +3,17 @@ import dyn2py
INPUT_DIR = "tests/input_files"
OUTPUT_DIR = "tests/output_files"
TEMP_DIR = "tests/temp_files"
def cleanup_output_dir():
output_dir = pathlib.Path(OUTPUT_DIR)
if output_dir.exists():
for f in output_dir.iterdir():
def cleanup_dirs():
for p in [OUTPUT_DIR, TEMP_DIR]:
the_dir = pathlib.Path(p)
if the_dir.exists():
for f in the_dir.iterdir():
f.unlink()
else:
output_dir.mkdir()
the_dir.mkdir()
def extract_single_node_dyn(modify_py: bool = False):
@@ -23,7 +25,7 @@ def extract_single_node_dyn(modify_py: bool = False):
modify_py (bool, optional): Also do some changes on the exported file. Defaults to False.
"""
cleanup_output_dir()
cleanup_dirs()
# Extract py:
options = dyn2py.Options(python_folder=OUTPUT_DIR)

View File

@@ -36,8 +36,12 @@ class TestCommandLine(unittest.TestCase):
self.assertFalse(p.stderr)
dyn_sources = [
{"filename": "python_nodes.dyn", "py_file_count": 6},
{"filename": "single_node.dyn", "py_file_count": 1}
{"filename": "python_nodes.dyn", "output_file_count": 6},
{"filename": "single_node.dyn", "output_file_count": 1}
]
py_sources = [
{"filename": "single_node_mod.py"},
]
dyn_sources_error = ["dynamo1file.dyn",
@@ -46,6 +50,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)
@@ -76,7 +89,7 @@ class TestCommandLine(unittest.TestCase):
test_dicts[0]["filenames"] = [
test_dicts[0]["filename"]]
if i == 0:
if i == 0 and len(source_dict) > 1:
# Create a multi file version on the first file:
d = {}
for key in source_dict:
@@ -91,7 +104,7 @@ class TestCommandLine(unittest.TestCase):
for test_dict in test_dicts:
if pfolder_option:
file_dir = INPUT_DIR
file_dir = TEMP_DIR
pfolder_arg = f"{pfolder_option} {OUTPUT_DIR}"
else:
file_dir = OUTPUT_DIR
@@ -113,7 +126,7 @@ class TestCommandLine(unittest.TestCase):
def test_dyn_error(self):
for s in self.dyn_sources_error:
cleanup_output_dir()
cleanup_dirs()
if pathlib.Path(f"{INPUT_DIR}/{s}").exists():
shutil.copy(f"{INPUT_DIR}/{s}",
@@ -131,29 +144,40 @@ class TestCommandLine(unittest.TestCase):
dyn_tests = self.generate_test_args(self.dyn_sources)
for s in dyn_tests:
cleanup_output_dir()
cleanup_dirs()
# if no pythonfolder, everything should be in output ddir
if not s["pfolder_arg"]:
source_dir = OUTPUT_DIR
else:
source_dir = TEMP_DIR
# copy source files:
for filename in s["filenames"]:
shutil.copy(f"{INPUT_DIR}/{filename}",
f"{OUTPUT_DIR}/{filename}")
f"{source_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(
len(file_open["python_file_mtimes"]), s["py_file_count"])
len(file_open["python_file_mtimes"]), s["output_file_count"])
# Test no overwrite
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"])
len(file_no_overwrite["python_file_mtimes"]), s["output_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 +186,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"])
len(file_force["python_file_mtimes"]), s["output_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,18 +204,52 @@ 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["output_file_count"] * 2,
msg=f""
)
for p in file_force["python_file_mtimes"]:
self.assertTrue(
file_backup["python_file_mtimes"][p] > file_force["python_file_mtimes"][p]
)
# def test_py(self):
# py_tests = self.generate_test_args(self.py_sources)
# # TODO add more python files!
# self.assertEqual(len(py_tests), 1)
# for s in py_tests:
# cleanup_dirs()
# extract_single_node_dyn(modify_py=True)
# # if pythonfolder, python should be in the temp folder:
# if s["pfolder_arg"]:
# for filename in s["filenames"]:
# shutil.move(f"{OUTPUT_DIR}/{filename}",
# f"{TEMP_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(
# len(file_open["python_file_mtimes"]), s["output_file_count"])
def test_single_dyn_dryrun(self):
for s in self.dyn_sources:
for arg in ["-n", "--dry-run"]:
cleanup_output_dir()
cleanup_dirs()
shutil.copy(f"{INPUT_DIR}/{s['filename']}",
f"{OUTPUT_DIR}/{s['filename']}")

View File

@@ -43,7 +43,7 @@ class TestDynamoFile(unittest.TestCase):
dyn.get_python_node_by_id("wrongid")
def test_extract_python(self):
cleanup_output_dir()
cleanup_dirs()
dyn2py.PythonFile.open_files.clear()
opt = dyn2py.Options(python_folder=OUTPUT_DIR)
@@ -69,7 +69,7 @@ class TestDynamoFile(unittest.TestCase):
dyn2py.DynamoFile.get_open_file_by_uuid("76de5c79-17c5-4c74-9f90-ad99a213d339"))
def test_get_related_python_files(self):
cleanup_output_dir()
cleanup_dirs()
opt = dyn2py.Options(python_folder=OUTPUT_DIR)
dyn1 = dyn2py.DynamoFile(f"{INPUT_DIR}/python_nodes.dyn")
@@ -91,7 +91,7 @@ class TestDynamoFile(unittest.TestCase):
self.assertFalse(no_python_files)
def test_write_same(self):
cleanup_output_dir()
cleanup_dirs()
shutil.copy(f"{INPUT_DIR}/python_nodes.dyn",
f"{OUTPUT_DIR}/python_nodes.dyn")

View File

@@ -8,8 +8,6 @@ from tests.support import *
class TestFile(unittest.TestCase):
# Write methods should be tested in subclasses!
def test_init(self):
paths = [
f"{INPUT_DIR}/python_nodes.dyn",
@@ -71,7 +69,7 @@ class TestFile(unittest.TestCase):
nonexisting_file = dyn2py.File(f"{INPUT_DIR}/new_file.py")
# Extract a python file so it is always newer than the others:
cleanup_output_dir()
cleanup_dirs()
opt = dyn2py.Options(python_folder=OUTPUT_DIR)
older_file.extract_python(options=opt) # type: ignore
for f in dyn2py.PythonFile.open_files:
@@ -103,3 +101,15 @@ class TestFile(unittest.TestCase):
self.assertEqual(file.is_dynamo_file(), f == "dyn")
self.assertEqual(file.is_python_file(), f == "py")
def test_write(self):
# new empty file:
empty_filepath = pathlib.Path(f"{OUTPUT_DIR}/empty.txt")
empty_filepath.touch()
empty_file = dyn2py.File(f"{OUTPUT_DIR}/empty.txt")
self.assertTrue(empty_file.exists)
with self.assertRaises(TypeError):
empty_file.write()