diff --git a/.github/workflows/check-commit-actions.yml b/.github/workflows/check-commit-actions.yml deleted file mode 100644 index 59ca701..0000000 --- a/.github/workflows/check-commit-actions.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: tests -on: - push: - branches: - - main - paths-ignore: - - 'README.md' - - 'LICENSE' - - 'cq-cli_pyinstaller.spec' - - '.github/workflows/pyinstaller-builds-actions.yml' -jobs: - run-pytest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - steps: - - uses: actions/checkout@v2 - - name: Install CadQuery and pytest - shell: bash --login {0} - run: | - pip install --upgrade pip - pip install --pre git+https://github.com/CadQuery/cadquery.git - pip install pytest - - name: Run tests - shell: bash --login {0} - run: | - pytest -v - # run-pytest-macos: - # runs-on: macos-latest - # strategy: - # matrix: - # python-version: ["3.10"] - # steps: - # - uses: actions/checkout@v2 - # - uses: conda-incubator/setup-miniconda@v2 - # with: - # miniconda-version: "latest" - # python-version: 3.8 - # activate-environment: test - # - name: Install CadQuery and pytest - # shell: bash --login {0} - # run: | - # conda info - # conda install -c cadquery -c conda-forge cadquery=master ocp=7.5.2 python=3.8 - # conda install -c anaconda pytest - # - name: Run tests - # shell: bash --login {0} - # run: | - # conda info - # pytest -v - run-pytest-win: - runs-on: "windows-latest" - strategy: - matrix: - python-version: ["3.10"] - steps: - - uses: actions/checkout@v2 - - name: Install CadQuery and pytest - shell: pwsh - run: | - pip install --upgrade pip - pip install --pre git+https://github.com/CadQuery/cadquery.git - pip install pytest - - name: Run tests - shell: pwsh - run: | - pytest -v diff --git a/.github/workflows/check-pr-actions.yml b/.github/workflows/check-pr-actions.yml deleted file mode 100644 index 4ac2a43..0000000 --- a/.github/workflows/check-pr-actions.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: check-pr-commit -on: - pull_request: - branches: - - main - paths-ignore: - - 'README.md' - - 'LICENSE' - - 'cq-cli_pyinstaller.spec' - - '.github/workflows/pyinstaller-builds-actions.yml' -jobs: - run-pytest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - steps: - - uses: actions/checkout@v2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - name: Install CadQuery and pytest - shell: bash --login {0} - run: | - pip install --upgrade pip - pip install --pre git+https://github.com/CadQuery/cadquery.git - pip install pytest - - name: Run tests - shell: bash --login {0} - run: | - pytest -v - # run-pytest-macos: - # runs-on: macos-latest - # strategy: - # matrix: - # python-version: ["3.10"] - # steps: - # - uses: actions/checkout@v2 - # with: - # ref: ${{ github.event.pull_request.head.sha }} - # - name: Install CadQuery and pytest - # shell: bash --login {0} - # run: | - # python3 -m ensurepip - # pip3 install --upgrade pip - # pip3 install --pre git+https://github.com/CadQuery/cadquery.git - # pip3 install pytest - # - name: Run tests - # shell: bash --login {0} - # run: | - # pytest -v - run-pytest-win: - runs-on: "windows-latest" - strategy: - matrix: - python-version: ["3.10"] - steps: - - uses: actions/checkout@v2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - name: Install CadQuery and pytest - shell: pwsh - run: | - pip install --upgrade pip - pip install --pre git+https://github.com/CadQuery/cadquery.git - pip install pytest - - name: Run tests - shell: pwsh - run: | - pytest -v diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..28a6acb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: lint +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + run-black-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v2 + - name: Install CadQuery and pytest + shell: bash --login {0} + run: | + pip install --upgrade pip + pip install -e . + pip install -e .[dev] + - name: Run tests + shell: bash --login {0} + run: | + black --diff --check . diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..974f8c2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: tests +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + run-pytest: + strategy: + fail-fast: false + matrix: + python-version: ["3.10"] + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Dependencies (Linux) + run: sudo apt-get update && sudo apt install -y libgl1-mesa-glx + if: matrix.os == 'ubuntu-latest' + - uses: actions/checkout@v2 + - name: Install CadQuery and pytest + run: | + pip3 install --upgrade pip + pip3 install -e . + pip3 install -e .[dev] + - name: Run tests + run: | + python3 -m pytest -v diff --git a/README.md b/README.md index 04bffc0..ede0f56 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cq-cli -[![tests](https://github.com/CadQuery/cq-cli/actions/workflows/check-commit-actions.yml/badge.svg)](https://github.com/CadQuery/cq-cli/actions) +[![tests](https://github.com/CadQuery/cq-cli/actions/workflows/tests.yml/badge.svg)](https://github.com/CadQuery/cq-cli/actions) ## Contents diff --git a/pyinstaller.spec b/pyinstaller.spec new file mode 100644 index 0000000..cda6d99 --- /dev/null +++ b/pyinstaller.spec @@ -0,0 +1,106 @@ +# -*- mode: python ; coding: utf-8 -*- +import sys, site, os +import glob +from path import Path +from PyInstaller.utils.hooks import collect_submodules + +# Whether we are running in onefile or dir mode +onefile_mode = True +if len(sys.argv) == 3: + if sys.argv[2] == 'onefile': + onefile_mode = True + elif sys.argv[2] == 'dir': + onefile_mode = False + +block_cipher = None +# if sys.platform == 'linux': +# occt_dir = os.path.join(Path(sys.prefix), 'share', 'opencascade') +# ocp_path = (os.path.join(HOMEPATH, 'OCP.cpython-38-x86_64-linux-gnu.so'), '.') +# elif sys.platform == 'darwin': +# occt_dir = os.path.join(Path(sys.prefix), 'Library', 'share', 'opencascade') +# ocp_path = (os.path.join(HOMEPATH, 'OCP.cpython-38-darwin.so'), '.') +# elif sys.platform == 'win32': +# occt_dir = os.path.join(Path(sys.prefix), 'Library', 'share', 'opencascade') +# ocp_path = (os.path.join(HOMEPATH, 'OCP.cp38-win_amd64.pyd'), '.') + +# Dynamically find all the modules in the cqcodecs directory +hidden_imports = [] +file_list = glob.glob('.' + os.path.sep + "src" + os.path.sep + "cq_cli" + os.path.sep + 'cqcodecs' + os.path.sep + 'cq_codec_*.py') +for file_path in file_list: + file_name = file_path.split(os.path.sep)[-1] + module_name = file_name.replace(".py", "") + hidden_imports.append("cqcodecs." + module_name) +hidden_imports.append('OCP') +hidden_imports.append('typing_extensions') +hidden_imports.append('pyparsing') +hidden_imports.append('ezdxf') +hidden_imports.append('nptyping') +hidden_imports.append('typish') +hidden_imports.append('numpy.core.dtype') +hidden_imports.append('numpy.core._dtype') +hidden_imports.append('vtkmodules') +hidden_imports.append('vtkmodules.all') + +# numpy hidden imports +hidden_imports_numpy = collect_submodules('numpy') +hidden_imports = hidden_imports + hidden_imports_numpy + +a = Analysis(['src/cq_cli/cq_cli.py'], + pathex=['.'], + #binaries=[ + # ocp_path + #], + datas=[ + (os.path.join(os.path.dirname(os.path.realpath('__file__')), 'src', 'cq_cli', 'cqcodecs'), 'cqcodecs') + ], + hiddenimports=hidden_imports, + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +# Select between onefile and dir mode executables +if onefile_mode: + exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='cq-cli', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=True ) +else: + exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='cq-cli', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True ) + +exclude = ('libGL','libEGL','libbsd') +a.binaries = TOC([x for x in a.binaries if not x[0].startswith(exclude)]) + +if not onefile_mode: + coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='cq-cli') diff --git a/pyproject.toml b/pyproject.toml index a733bcb..62d288c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "cq-cli" -version = "2.1.0" -license = "Apache-2.0" +name = "cq_cli" +version = "2.3.0" +license = {file = "LICENSE"} authors = [ { name="Jeremy Wright" }, ] @@ -20,6 +20,13 @@ dependencies = [ 'cadquery >= 2.3.1', ] +[project.optional-dependencies] +dev = [ + "pytest", + "black==19.10b0", + "click==8.0.4" +] + [project.urls] "Homepage" = "https://github.com/CadQuery/cq-cli" "Bug Tracker" = "https://github.com/CadQuery/cq-cli/issues" diff --git a/__init__.py b/src/cq_cli/__init__.py similarity index 100% rename from __init__.py rename to src/cq_cli/__init__.py diff --git a/cq-cli.py b/src/cq_cli/cq_cli.py similarity index 73% rename from cq-cli.py rename to src/cq_cli/cq_cli.py index c76e20c..08dc107 100755 --- a/cq-cli.py +++ b/src/cq_cli/cq_cli.py @@ -1,4 +1,10 @@ #!/usr/bin/env python3 + +# surprisingly, this seems to fix issues on macOS - it shouldn't be necessary +# for any python3 installation, but this program fails to run on macOS without +# this import. +from __future__ import print_function + import os import sys import argparse @@ -9,11 +15,12 @@ import json from cqcodecs import loader + def build_and_parse(script_str, params, errfile): """ Uses CQGI to parse and build a script, substituting in parameters if any were supplied. """ - # We need to do a broad try/catch to let the user know if something higher-level fails + # We need to do a broad try/catch to let the user know if something higher-level fails try: # Do the CQGI handling of the script here and, if successful, pass the build result to the codec cqModel = cqgi.parse(script_str) @@ -22,7 +29,7 @@ def build_and_parse(script_str, params, errfile): # Handle the case of the build not being successful, otherwise pass the codec the build result if not build_result.success: # Re-throw the exception so that it will be caught and formatted correctly - raise(build_result.exception) + raise (build_result.exception) else: return build_result except Exception: @@ -30,7 +37,7 @@ def build_and_parse(script_str, params, errfile): # If there was an error file specified write to that, otherwise send it to stderr if errfile != None: - with open(errfile, 'w') as file: + with open(errfile, "w") as file: file.write(str(out_tb)) else: print(str(out_tb), file=sys.stderr) @@ -41,6 +48,7 @@ def build_and_parse(script_str, params, errfile): # Return None here to prevent a failed build from slipping through return None + def get_script_from_infile(infile, outfile, errfile): """ Gets the CadQuery script from the infile location. @@ -58,7 +66,7 @@ def get_script_from_infile(infile, outfile, errfile): if errfile == None: print("infile does not exist.", file=sys.stderr) else: - with open(errfile, 'w') as file: + with open(errfile, "w") as file: file.write("Argument error: infile does not exist.") return None @@ -68,7 +76,7 @@ def get_script_from_infile(infile, outfile, errfile): # Grab the string from stdin script_str = sys.stdin.read() else: - with open(infile, 'r') as file: + with open(infile, "r") as file: script_str = file.read() return script_str @@ -96,7 +104,7 @@ def get_params_from_file(param_json_path, errfile): # Make sure that the file exists if os.path.isfile(param_json_path): # Read the contents of the file - with open(param_json_path, 'r') as file: + with open(param_json_path, "r") as file: params_json = file.read() param_dict_array = json.loads(params_json) @@ -112,10 +120,15 @@ def get_params_from_file(param_json_path, errfile): param_dict[key] = param_dict_array[key] else: if errfile == None: - print("Parameter file does not exist, default parameters will be used. ", file=sys.stderr) + print( + "Parameter file does not exist, default parameters will be used. ", + file=sys.stderr, + ) else: - with open(errfile, 'w') as file: - file.write("Argument error: Parameter file does not exist, default parameters will be used.") + with open(errfile, "w") as file: + file.write( + "Argument error: Parameter file does not exist, default parameters will be used." + ) return param_dict @@ -131,21 +144,52 @@ def main(): loaded_codecs = loader.load_codecs() # Parse the command line arguments - parser = argparse.ArgumentParser(description='Command line utility for converting CadQuery script output to various other output formats.') - parser.add_argument('--codec', help='The codec to use when converting the CadQuery output. Must match the name of a codec file in the cqcodecs directory.') - parser.add_argument('--getparams', help='Analyzes the script and returns a JSON string with the parameter information.') - parser.add_argument('--infile', help='The input CadQuery script to convert.') - parser.add_argument('--outfile', help='File to write the converted CadQuery output to. Prints to stdout if not specified.') - parser.add_argument('--errfile', help='File to write any errors to. Prints to stderr if not specified.') - parser.add_argument('--params', help='A colon and semicolon delimited string (no spaces) of key/value pairs representing variables and their values in the CadQuery script. i.e. var1:10.0;var2:4.0;') - parser.add_argument('--outputopts', dest='opts', help='A colon and semicolon delimited string (no spaces) of key/value pairs representing options to pass to the selected codec. i.e. width:100;height:200;') - parser.add_argument('--validate', help='Setting to true forces the CLI to only parse and validate the script and not produce converted output.') + parser = argparse.ArgumentParser( + description="Command line utility for converting CadQuery script output to various other output formats." + ) + parser.add_argument( + "--codec", + help="The codec to use when converting the CadQuery output. Must match the name of a codec file in the cqcodecs directory.", + ) + parser.add_argument( + "--getparams", + help="Analyzes the script and returns a JSON string with the parameter information.", + ) + parser.add_argument("--infile", help="The input CadQuery script to convert.") + parser.add_argument( + "--outfile", + help="File to write the converted CadQuery output to. Prints to stdout if not specified.", + ) + parser.add_argument( + "--errfile", + help="File to write any errors to. Prints to stderr if not specified.", + ) + parser.add_argument( + "--params", + help="A colon and semicolon delimited string (no spaces) of key/value pairs representing variables and their values in the CadQuery script. i.e. var1:10.0;var2:4.0;", + ) + parser.add_argument( + "--outputopts", + dest="opts", + help="A colon and semicolon delimited string (no spaces) of key/value pairs representing options to pass to the selected codec. i.e. width:100;height:200;", + ) + parser.add_argument( + "--validate", + help="Setting to true forces the CLI to only parse and validate the script and not produce converted output.", + ) args = parser.parse_args() # Make sure that the user has at least specified the validate or codec arguments - if args.validate == None and args.infile == None and args.codec == None and args.outfile == None: - print("Please specify at least the validate option plus an infile, or an infile and an outfile or a codec.") + if ( + args.validate == None + and args.infile == None + and args.codec == None + and args.outfile == None + ): + print( + "Please specify at least the validate option plus an infile, or an infile and an outfile or a codec." + ) parser.print_help(sys.stderr) sys.exit(2) @@ -167,9 +211,10 @@ def main(): # Validation handling # # If the user wants to validate, do that and exit - if args.validate == 'true': + if args.validate == "true": script_str = get_script_from_infile(args.infile, outfile, errfile) - if script_str == None: sys.exit(1) + if script_str == None: + sys.exit(1) # Set the PYTHONPATH variable to the current directory to allow module loading set_pythonpath_for_infile(args.infile) @@ -180,10 +225,10 @@ def main(): if build_result != None and build_result.success: # Let the user know that the validation was a success if outfile != None: - with open(outfile, 'w') as file: - file.write('validation_success') + with open(outfile, "w") as file: + file.write("validation_success") else: - print('validation_success') + print("validation_success") return 0 @@ -198,7 +243,8 @@ def main(): # Load the script string script_str = get_script_from_infile(args.infile, outfile, errfile) - if script_str == None: sys.exit(1) + if script_str == None: + sys.exit(1) # Set the PYTHONPATH variable to the current directory to allow module loading set_pythonpath_for_infile(args.infile) @@ -249,10 +295,10 @@ def main(): params.append(new_dict) # Write the converted output to the appropriate place based on the command line arguments - if args.getparams == 'true': + if args.getparams == "true": print(json.dumps(params)) else: - with open(args.getparams, 'w') as file: + with open(args.getparams, "w") as file: file.write(json.dumps(params)) # Check to see if the user only cared about getting the params @@ -268,24 +314,26 @@ def main(): # Attempt to auto-detect the codec if the user has not set the option if args.outfile != None and args.codec == None: # Determine the codec from the file extension - codec_temp = args.outfile.split('.')[-1] + codec_temp = args.outfile.split(".")[-1] if codec_temp != None: codec_temp = "cq_codec_" + codec_temp if codec_temp in loaded_codecs: codec = codec_temp # If the user has not supplied a codec, they need to be validating the script - if (codec == None and args.outfile == None) and (args.validate == None or args.validate == 'false'): + if (codec == None and args.outfile == None) and ( + args.validate == None or args.validate == "false" + ): print("Please specify a valid codec. You have the following to choose from:") for key in loaded_codecs: - print(key.replace('cq_codec_', '')) + print(key.replace("cq_codec_", "")) sys.exit(3) # If the codec is None at this point, the user specified an invalid codec if codec == None: print("Please specify a valid codec. You have the following to choose from:") for key in loaded_codecs: - print(key.replace('cq_codec_', '')) + print(key.replace("cq_codec_", "")) sys.exit(3) for key in loaded_codecs: @@ -300,7 +348,8 @@ def main(): # Grab the script input from a file path or stdin script_str = get_script_from_infile(infile, outfile, errfile) - if script_str == None: sys.exit(1) + if script_str == None: + sys.exit(1) # Set the PYTHONPATH variable to the current directory to allow module loading set_pythonpath_for_infile(args.infile) @@ -311,7 +360,13 @@ def main(): # Check whether any parameters were passed if args.params != None: # We have been passed a directory - if args.params.startswith('/') or args.params.startswith('.') or args.params.startswith('..') or args.params.startswith('~') or args.params[1] == ':': + if ( + args.params.startswith("/") + or args.params.startswith(".") + or args.params.startswith("..") + or args.params.startswith("~") + or args.params[1] == ":" + ): # Load the parameters dictionary from the file file_params = get_params_from_file(args.params, errfile) @@ -323,9 +378,9 @@ def main(): params = json.loads(args.params) else: # Convert the string of parameters into a params dictionary - groups = args.params.split(';') + groups = args.params.split(";") for group in groups: - param_parts = group.split(':') + param_parts = group.split(":") # Protect against a trailing semi-colon if len(param_parts) == 2: params[param_parts[0]] = param_parts[1] @@ -336,9 +391,9 @@ def main(): # Check whether any output options were passed if args.opts != None: # Convert the string of options into a output_opts dictionary - groups = args.opts.split(';') + groups = args.opts.split(";") for group in groups: - opt_parts = group.split(':') + opt_parts = group.split(":") # Protect against a trailing semi-colon if len(opt_parts) == 2: op1 = opt_parts[1] @@ -347,7 +402,12 @@ def main(): if op1 == "True" or op1 == "False": op = opt_parts[1] == "True" elif op1[:1] == "(": - op = tuple(map(float, opt_parts[1].replace("(", "").replace(")", "").split(','))) + op = tuple( + map( + float, + opt_parts[1].replace("(", "").replace(")", "").split(","), + ) + ) elif "." in op1: op = float(opt_parts[1]) else: @@ -370,7 +430,7 @@ def main(): if errfile == None: print("build_and_parse error: " + str(err), file=sys.stderr) else: - with open(errfile, 'w') as file: + with open(errfile, "w") as file: file.write(err) sys.exit(100) @@ -389,13 +449,16 @@ def main(): print(converted) else: if isinstance(converted, str): - with open(outfile, 'w') as file: + with open(outfile, "w") as file: file.write(converted) elif isinstance(converted, (bytes, bytearray)): - with open(outfile, 'wb') as file: + with open(outfile, "wb") as file: file.write(converted) else: - raise TypeError("Expected converted output to be str, bytes, or bytearray. Got '%s'" % type(converted).__name__) + raise TypeError( + "Expected converted output to be str, bytes, or bytearray. Got '%s'" + % type(converted).__name__ + ) except Exception: out_tb = traceback.format_exc() @@ -404,10 +467,11 @@ def main(): if errfile == None: print("Conversion codec error: " + str(out_tb), file=sys.stderr) else: - with open(errfile, 'w') as file: + with open(errfile, "w") as file: file.write(str(out_tb)) sys.exit(200) + if __name__ == "__main__": main() diff --git a/cqcodecs/__init__.py b/src/cq_cli/cqcodecs/__init__.py similarity index 100% rename from cqcodecs/__init__.py rename to src/cq_cli/cqcodecs/__init__.py diff --git a/cqcodecs/codec_helpers.py b/src/cq_cli/cqcodecs/codec_helpers.py similarity index 60% rename from cqcodecs/codec_helpers.py rename to src/cq_cli/cqcodecs/codec_helpers.py index e4c3311..8cfc228 100644 --- a/cqcodecs/codec_helpers.py +++ b/src/cq_cli/cqcodecs/codec_helpers.py @@ -1,9 +1,10 @@ -from contextlib import contextmanager,redirect_stderr,redirect_stdout +from contextlib import contextmanager, redirect_stderr, redirect_stdout from os import devnull + @contextmanager def suppress_stdout_stderr(): """A context manager that redirects stdout and stderr to devnull""" - with open(devnull, 'w') as fnull: + with open(devnull, "w") as fnull: with redirect_stderr(fnull) as err, redirect_stdout(fnull) as out: - yield (err, out) \ No newline at end of file + yield (err, out) diff --git a/cqcodecs/cq_codec_gltf.py b/src/cq_cli/cqcodecs/cq_codec_gltf.py similarity index 81% rename from cqcodecs/cq_codec_gltf.py rename to src/cq_cli/cqcodecs/cq_codec_gltf.py index dc17a68..827a195 100644 --- a/cqcodecs/cq_codec_gltf.py +++ b/src/cq_cli/cqcodecs/cq_codec_gltf.py @@ -3,6 +3,7 @@ import cadquery as cq import cqcodecs.codec_helpers as helpers + def convert(build_result, output_file=None, error_file=None, output_opts=None): # Create a temporary file to put the STL output into temp_dir = tempfile.gettempdir() @@ -15,10 +16,12 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): if type(build_result.first_result.shape).__name__ == "Assembly": build_result.first_result.shape.save(temp_file, binary=False) else: - raise ValueError("GLTF export is only available for CadQuery assemblies at this time") + raise ValueError( + "GLTF export is only available for CadQuery assemblies at this time" + ) # Read the GLTF output back in - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: gltf_str = file.read() - return gltf_str \ No newline at end of file + return gltf_str diff --git a/cqcodecs/cq_codec_step.py b/src/cq_cli/cqcodecs/cq_codec_step.py similarity index 79% rename from cqcodecs/cq_codec_step.py rename to src/cq_cli/cqcodecs/cq_codec_step.py index 6bdec53..4968ed8 100644 --- a/cqcodecs/cq_codec_step.py +++ b/src/cq_cli/cqcodecs/cq_codec_step.py @@ -3,6 +3,7 @@ import cadquery as cq import cqcodecs.codec_helpers as helpers + def convert(build_result, output_file=None, error_file=None, output_opts=None): # Create a temporary file to put the STL output into temp_dir = tempfile.gettempdir() @@ -11,10 +12,12 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): # The exporters will add extra output that we do not want, so suppress it with helpers.suppress_stdout_stderr(): # Put the STEP output into the temp file - exporters.export(build_result.results[0].shape, temp_file, exporters.ExportTypes.STEP) + exporters.export( + build_result.results[0].shape, temp_file, exporters.ExportTypes.STEP + ) # Read the STEP output back in - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: step_str = file.read() return step_str diff --git a/cqcodecs/cq_codec_stl.py b/src/cq_cli/cqcodecs/cq_codec_stl.py similarity index 84% rename from cqcodecs/cq_codec_stl.py rename to src/cq_cli/cqcodecs/cq_codec_stl.py index 126ed1c..9eb55e6 100644 --- a/cqcodecs/cq_codec_stl.py +++ b/src/cq_cli/cqcodecs/cq_codec_stl.py @@ -3,6 +3,7 @@ import cadquery as cq import cqcodecs.codec_helpers as helpers + def convert(build_result, output_file=None, error_file=None, output_opts=None): # Create a temporary file to put the STL output into temp_dir = tempfile.gettempdir() @@ -20,10 +21,12 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): # The exporters will add extra output that we do not want, so suppress it with helpers.suppress_stdout_stderr(): # Put the STL output into the temp file - build_result.results[0].shape.val().exportStl(temp_file, linearDeflection, angularDeflection, True) + build_result.results[0].shape.val().exportStl( + temp_file, linearDeflection, angularDeflection, True + ) # Read the STL output back in - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: stl_str = file.read() return stl_str diff --git a/cqcodecs/cq_codec_svg.py b/src/cq_cli/cqcodecs/cq_codec_svg.py similarity index 71% rename from cqcodecs/cq_codec_svg.py rename to src/cq_cli/cqcodecs/cq_codec_svg.py index cf9a5e5..a3d6e99 100644 --- a/cqcodecs/cq_codec_svg.py +++ b/src/cq_cli/cqcodecs/cq_codec_svg.py @@ -3,6 +3,7 @@ import cadquery as cq import cqcodecs.codec_helpers as helpers + def convert(build_result, output_file=None, error_file=None, output_opts=None): # Create a temporary file to put the STL output into temp_dir = tempfile.gettempdir() @@ -11,10 +12,15 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): # The exporters will add extra output that we do not want, so suppress it with helpers.suppress_stdout_stderr(): # Put the STEP output into the temp file - exporters.export(build_result.results[0].shape, temp_file, exporters.ExportTypes.SVG, opt=output_opts) + exporters.export( + build_result.results[0].shape, + temp_file, + exporters.ExportTypes.SVG, + opt=output_opts, + ) # Read the STEP output back in - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: step_str = file.read() - return step_str \ No newline at end of file + return step_str diff --git a/cqcodecs/cq_codec_threejs.py b/src/cq_cli/cqcodecs/cq_codec_threejs.py similarity index 79% rename from cqcodecs/cq_codec_threejs.py rename to src/cq_cli/cqcodecs/cq_codec_threejs.py index c547d27..88aacbc 100644 --- a/cqcodecs/cq_codec_threejs.py +++ b/src/cq_cli/cqcodecs/cq_codec_threejs.py @@ -3,6 +3,7 @@ import cadquery as cq import cqcodecs.codec_helpers as helpers + def convert(build_result, output_file=None, error_file=None, output_opts=None): # Create a temporary file to put the STL output into temp_dir = tempfile.gettempdir() @@ -11,10 +12,12 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): # The exporters will add extra output that we do not want, so suppress it with helpers.suppress_stdout_stderr(): # Put the STEP output into the temp file - exporters.export(build_result.results[0].shape, temp_file, exporters.ExportTypes.TJS) + exporters.export( + build_result.results[0].shape, temp_file, exporters.ExportTypes.TJS + ) # Read the STEP output back in - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: tjs_str = file.read() return tjs_str diff --git a/cqcodecs/loader.py b/src/cq_cli/cqcodecs/loader.py similarity index 83% rename from cqcodecs/loader.py rename to src/cq_cli/cqcodecs/loader.py index feb5942..a98605b 100644 --- a/cqcodecs/loader.py +++ b/src/cq_cli/cqcodecs/loader.py @@ -2,12 +2,13 @@ import importlib import pkgutil + def load_codecs(): cq_codecs = {} # Search all of the modules in the current directory to find codecs for finder, name, ispkg in pkgutil.iter_modules([os.path.dirname(__file__)]): - if name.startswith('cq_codec_'): + if name.startswith("cq_codec_"): cq_codecs[name] = importlib.import_module("cqcodecs." + name) - return cq_codecs \ No newline at end of file + return cq_codecs diff --git a/tests/test_cli.py b/tests/test_cli.py index baebc26..0d7388b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,14 +3,20 @@ import tests.test_helpers as helpers import json + def test_no_cli_arguments(): """ Runs the CLI with no arguments, which you should not do unless you want the usage message. """ - command = ["python", "cq-cli.py"] + command = ["python", "src/cq_cli/cq_cli.py"] out, err, exitcode = helpers.cli_call(command) - assert out.decode().split('\n')[0].startswith("Please specify at least the validate option") + assert ( + out.decode() + .split("\n")[0] + .startswith("Please specify at least the validate option") + ) + def test_codec_and_infile_arguments_file_nonexistent(): """ @@ -18,21 +24,36 @@ def test_codec_and_infile_arguments_file_nonexistent(): """ test_file = helpers.get_test_file_location("noexist.py") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) assert err.decode().startswith("infile does not exist.") + def test_codec_and_infile_arguments(): """ Test the CLI with only the codec and infile set, with a file that exists. """ test_file = helpers.get_test_file_location("cube.py") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) + assert "ISO-10303-21;" in out.decode() - assert out.decode().split('\n')[0].replace('\r', '') == "ISO-10303-21;" def test_codec_infile_and_outfile_arguments(): """ @@ -44,15 +65,25 @@ def test_codec_infile_and_outfile_arguments(): temp_dir = tempfile.gettempdir() temp_file = os.path.join(temp_dir, "temp_test_4.step") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file, '--outfile', temp_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + temp_file, + ] out, err, exitcode = helpers.cli_call(command) # Read the STEP output back from the outfile - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") + def test_codec_infile_outfile_errfile_arguments(): """ Tests the CLI with the codec, infile, outfile and errfile parameters set. @@ -65,11 +96,22 @@ def test_codec_infile_outfile_errfile_arguments(): temp_file = os.path.join(temp_dir, "temp_test_5.step") err_file = os.path.join(temp_dir, "temp_test_5_error.txt") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file, '--outfile', temp_file, '--errfile', err_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + temp_file, + "--errfile", + err_file, + ] out, err, exitcode = helpers.cli_call(command) # Read the error back from the errfile - with open(err_file, 'r') as file: + with open(err_file, "r") as file: err_str = file.read() assert err_str == "Argument error: infile does not exist." @@ -86,11 +128,22 @@ def test_parameter_file(): temp_dir = tempfile.gettempdir() temp_file = os.path.join(temp_dir, "temp_test_6.step") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file, '--outfile', temp_file, '--params', params_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + temp_file, + "--params", + params_file, + ] out, err, exitcode = helpers.cli_call(command) # Read the STEP output back from the outfile - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") @@ -106,11 +159,22 @@ def test_parameter_json_string(): temp_dir = tempfile.gettempdir() temp_file = os.path.join(temp_dir, "temp_test_7.step") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file, '--outfile', temp_file, '--params', "{\"width\":10}"] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + temp_file, + "--params", + '{"width":10}', + ] out, err, exitcode = helpers.cli_call(command) # Read the STEP output back from the outfile - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") @@ -126,11 +190,22 @@ def test_parameter_delimited_string(): temp_dir = tempfile.gettempdir() temp_file = os.path.join(temp_dir, "temp_test_8.step") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file, '--outfile', temp_file, '--params', "width:10;"] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + temp_file, + "--params", + "width:10;", + ] out, err, exitcode = helpers.cli_call(command) # Read the STEP output back from the outfile - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") @@ -142,7 +217,14 @@ def test_parameter_analysis(): """ test_file = helpers.get_test_file_location("cube_params.py") - command = ["python", "cq-cli.py", "--getparams", "true", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--getparams", + "true", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) # Grab the JSON output from cq-cli @@ -176,25 +258,50 @@ def test_parameter_file_input_output(): temp_file = os.path.join(temp_dir, "temp_test_9.json") # Save the parameters from the script to a file - command = ["python", "cq-cli.py", "--getparams", temp_file, "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--getparams", + temp_file, + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) # Run the script with baseline parameters - command2 = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file, '--params', temp_file] + command2 = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + "--params", + temp_file, + ] out2, err2, exitcode2 = helpers.cli_call(command2) assert err2.decode() == "" # Modify the parameters file - with open(temp_file, 'r') as file: + with open(temp_file, "r") as file: json_str = file.read() json_dict = json.loads(json_str) - json_dict[0]['initial'] = 10 + json_dict[0]["initial"] = 10 with open(temp_file, "w") as file: file.writelines(json.dumps(json_dict)) # Run the command with the new parameters - command3 = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file, '--params', temp_file] + command3 = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + "--params", + temp_file, + ] out3, err3, exitcode3 = helpers.cli_call(command3) # Make sure that the file output changed @@ -223,28 +330,50 @@ def test_params_stl_output(): file.writelines(json.dumps(params_json)) # Execute the script with the current parameters and save the new parameter metadata to the customizer file - command = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file, '--outfile', output_file_path, "--params", params_json_file_path, "--getparams", customizer_file_path ] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + output_file_path, + "--params", + params_json_file_path, + "--getparams", + customizer_file_path, + ] out, err, exitcode = helpers.cli_call(command) # Make sure there was no error assert err.decode() == "" # Make sure that the customizer.json file exists and has what we expect in it - with open(customizer_file_path, 'r') as file2: + with open(customizer_file_path, "r") as file2: json_str = file2.read() json_dict = json.loads(json_str) - assert json_dict[0]['initial'] == 1 - assert json_dict[1]['initial'] == "cube" - assert json_dict[2]['initial'] == True + assert json_dict[0]["initial"] == 1 + assert json_dict[1]["initial"] == "cube" + assert json_dict[2]["initial"] == True # Write an STL using the default parameters so that we can compare it to what was generated with customized parameters - command = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file, '--outfile', default_output_file_path ] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + default_output_file_path, + ] out2, err2, exitcode2 = helpers.cli_call(command) # Compare the two files to make sure they are different - with open(output_file_path, 'r') as file3: + with open(output_file_path, "r") as file3: stl_output_with_params = file3.read() - with open(default_output_file_path, 'r') as file4: + with open(default_output_file_path, "r") as file4: default_stl = file4.read() assert stl_output_with_params != default_stl @@ -256,7 +385,7 @@ def test_exit_codes(): """ # Test to make sure we get the correct exit code when no parameters are specified - command = ["python", "cq-cli.py" ] + command = ["python", "src/cq_cli/cq_cli.py"] out, err, exitcode = helpers.cli_call(command) # Make sure that we got exit code 2 @@ -266,7 +395,14 @@ def test_exit_codes(): test_input_file = helpers.get_test_file_location("impossible_cube.py") # Execute the script with the current parameters and save the new parameter metadata to the customizer file - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_input_file ] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_input_file, + ] out, err, exitcode = helpers.cli_call(command) # Make sure that we got exit code 100 for a failed model build diff --git a/tests/test_gltf_codec.py b/tests/test_gltf_codec.py index 53da3da..dd3b8df 100644 --- a/tests/test_gltf_codec.py +++ b/tests/test_gltf_codec.py @@ -1,14 +1,24 @@ import pytest import tests.test_helpers as helpers -@pytest.mark.skip(reason="Waiting on #1414 on the CadQuery repo to be merged to finish this") + +@pytest.mark.skip( + reason="Waiting on #1414 on the CadQuery repo to be merged to finish this" +) def test_gltf_codec(): """ Basic test of the GLTF codec plugin. """ test_file = helpers.get_test_file_location("cube_assy.py") - command = ["python", "cq-cli.py", "--codec", "gltf", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "gltf", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) - assert out.decode().split('\n')[0].replace('\r', '').startswith("{\"accessors\":") + assert out.decode().split("\n")[0].replace("\r", "").startswith('{"accessors":') diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f1592a0..631aa04 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,6 +1,7 @@ import os import subprocess + def get_test_file_location(file_name): """ Combines the testdata directory path with a filename for a test. @@ -9,13 +10,11 @@ def get_test_file_location(file_name): return os.path.join(test_data_dir, file_name) + def cli_call(command): """ Makes the operating system process calls to test the CLI properly. """ - proc = subprocess.Popen(command, - stdout = subprocess.PIPE, - stderr = subprocess.PIPE, - ) - out,err = proc.communicate() - return out, err, proc.returncode \ No newline at end of file + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,) + out, err = proc.communicate() + return out, err, proc.returncode diff --git a/tests/test_step_codec.py b/tests/test_step_codec.py index 8f349bd..478be12 100644 --- a/tests/test_step_codec.py +++ b/tests/test_step_codec.py @@ -1,12 +1,20 @@ import tests.test_helpers as helpers + def test_step_codec(): """ Basic test of the STEP codec plugin. """ test_file = helpers.get_test_file_location("cube.py") - command = ["python", "cq-cli.py", "--codec", "step", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "step", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) - assert out.decode().split('\n')[0].replace('\r', '') == "ISO-10303-21;" \ No newline at end of file + assert "ISO-10303-21;" in out.decode() diff --git a/tests/test_stl_codec.py b/tests/test_stl_codec.py index 945c51c..ce233c0 100644 --- a/tests/test_stl_codec.py +++ b/tests/test_stl_codec.py @@ -1,16 +1,25 @@ import os import tests.test_helpers as helpers + def test_stl_codec(): """ Basic test of the STL codec plugin. """ test_file = helpers.get_test_file_location("cube.py") - command = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) - assert out.decode().split('\n')[0].replace('\r', '') == "solid " + assert out.decode().split("\n")[0].replace("\r", "") == "solid " + def test_stl_codec_quality(): """ @@ -18,17 +27,33 @@ def test_stl_codec_quality(): """ test_file = helpers.get_test_file_location("sphere.py") - command = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) # Keep track of the number of lines for each STL as an approximate measure of quality - high_detail = len(out.decode().split('\n')) + high_detail = len(out.decode().split("\n")) # Attempt to adjust the quality of the resulting STL - command2 = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file, "--outputopts", "linearDeflection:0.3;angularDeflection:0.3"] + command2 = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "stl", + "--infile", + test_file, + "--outputopts", + "linearDeflection:0.3;angularDeflection:0.3", + ] out2, err2, exitcode2 = helpers.cli_call(command2) # Keep track of the number of lines in the STL as an approximate measure of quality - low_detail = len(out2.decode().split('\n')) + low_detail = len(out2.decode().split("\n")) assert low_detail < high_detail diff --git a/tests/test_svg_codec.py b/tests/test_svg_codec.py index c8cbc16..2321e09 100644 --- a/tests/test_svg_codec.py +++ b/tests/test_svg_codec.py @@ -1,12 +1,25 @@ import tests.test_helpers as helpers + def test_svg_codec(): """ Basic test of the SVG codec plugin. """ test_file = helpers.get_test_file_location("cube.py") - command = ["python", "cq-cli.py", "--codec", "svg", "--infile", test_file, "--outputopts", "width:100;height:100;marginLeft:12;marginTop:12;showAxes:False;projectionDir:(0.5,0.5,0.5);strokeWidth:0.25;strokeColor:(255,0,0);hiddenColor:(0,0,255);showHidden:True;"] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "svg", + "--infile", + test_file, + "--outputopts", + "width:100;height:100;marginLeft:12;marginTop:12;showAxes:False;projectionDir:(0.5,0.5,0.5);strokeWidth:0.25;strokeColor:(255,0,0);hiddenColor:(0,0,255);showHidden:True;", + ] out, err, exitcode = helpers.cli_call(command) - assert out.decode().split('\n')[0].replace('\r', '') == "" \ No newline at end of file + assert ( + out.decode().split("\n")[0].replace("\r", "") + == '' + ) diff --git a/tests/test_threejs_codec.py b/tests/test_threejs_codec.py index 470176a..77d84ab 100644 --- a/tests/test_threejs_codec.py +++ b/tests/test_threejs_codec.py @@ -1,14 +1,26 @@ import tests.test_helpers as helpers + def test_threejs_codec(): """ Basic test of the TJS (Three.js) codec plugin. """ test_file = helpers.get_test_file_location("cube.py") - command = ["python", "cq-cli.py", "--codec", "threejs", "--infile", test_file] + command = [ + "python", + "src/cq_cli/cq_cli.py", + "--codec", + "threejs", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) print("Output New: " + str(out.decode())) print("Error: " + str(err)) - assert out.decode().split('\n')[5].replace('\r', '') == ' "vertices" : 24,' - assert out.decode().split('\n')[6].replace('\r', '') == ' "faces" : 12,' + assert ( + out.decode().split("\n")[5].replace("\r", "") == ' "vertices" : 24,' + ) + assert ( + out.decode().split("\n")[6].replace("\r", "") == ' "faces" : 12,' + ) diff --git a/tests/testdata/cube.py b/tests/testdata/cube.py index 776ac40..1dee3c5 100644 --- a/tests/testdata/cube.py +++ b/tests/testdata/cube.py @@ -2,4 +2,4 @@ cube = cq.Workplane().box(1, 1, 1) -show_object(cube) \ No newline at end of file +show_object(cube) diff --git a/tests/testdata/cube_assy.py b/tests/testdata/cube_assy.py index 28f8d89..4cf3273 100644 --- a/tests/testdata/cube_assy.py +++ b/tests/testdata/cube_assy.py @@ -4,4 +4,4 @@ assy.add(cq.Workplane().box(10, 10, 10)) assy.add(cq.Workplane().box(10, 10, 10), loc=cq.Location((5, 5, 0))) -show_object(assy) \ No newline at end of file +show_object(assy) diff --git a/tests/testdata/cube_params.py b/tests/testdata/cube_params.py index 13db639..da4f29d 100644 --- a/tests/testdata/cube_params.py +++ b/tests/testdata/cube_params.py @@ -6,4 +6,4 @@ cube = cq.Workplane().box(width, width, width, centered).tag(tag_name) -show_object(cube) \ No newline at end of file +show_object(cube) diff --git a/tests/testdata/impossible_cube.py b/tests/testdata/impossible_cube.py index 6a2f612..f1a3ee2 100644 --- a/tests/testdata/impossible_cube.py +++ b/tests/testdata/impossible_cube.py @@ -2,4 +2,4 @@ box = cq.Workplane().box(10, 10, 10).edges().fillet(400.0) -show_object(box) \ No newline at end of file +show_object(box) diff --git a/tests/testdata/sphere.py b/tests/testdata/sphere.py index e892f42..16cba21 100644 --- a/tests/testdata/sphere.py +++ b/tests/testdata/sphere.py @@ -2,4 +2,4 @@ sphere = cq.Workplane().sphere(1) -show_object(sphere) \ No newline at end of file +show_object(sphere)