diff --git a/.github/workflows/check-commit-actions.yml b/.github/workflows/check-commit-actions.yml deleted file mode 100644 index a138889..0000000 --- a/.github/workflows/check-commit-actions.yml +++ /dev/null @@ -1,74 +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 - - uses: conda-incubator/setup-miniconda@v2 - with: - miniconda-version: "latest" - python-version: 3.8 - activate-environment: test - - name: Install CadQuery and pytest - shell: pwsh - run: | - conda info - conda install -c cadquery -c conda-forge cadquery=master ocp=7.5.2 python=3.8 libnetcdf=4.7.4 - conda install -c anaconda pytest - - name: Run tests - shell: pwsh - run: | - conda info - 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..1b72983 --- /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 + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install CadQuery and pytest + shell: bash --login {0} + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install -e .[dev] + - name: Run tests + shell: bash --login {0} + run: | + black --diff --check --target-version py313 --extend-exclude 'tests/testdata/syntax_error.py' . diff --git a/.github/workflows/pyinstaller-builds-actions.yml b/.github/workflows/pyinstaller-builds-actions.yml index e61f503..59e64e5 100644 --- a/.github/workflows/pyinstaller-builds-actions.yml +++ b/.github/workflows/pyinstaller-builds-actions.yml @@ -1,7 +1,7 @@ name: build on: - schedule: - - cron: '0 0 * * 1' + # schedule: + # - cron: '0 0 * * 1' workflow_dispatch: inputs: type: @@ -10,40 +10,41 @@ on: default: 'dir' jobs: build-linux: - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: actions/checkout@v6 + - uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: "latest" + # miniconda-version: "latest" + miniforge-version: latest auto-update-conda: true - python-version: 3.8 + python-version: 3.11 activate-environment: test - name: Install CadQuery and pyinstaller shell: bash --login {0} run: | - conda info - conda install -c cadquery -c conda-forge cadquery=master python=3.8 - conda install -c conda-forge pyinstaller + mamba info + mamba install -c cadquery -c conda-forge cadquery=master python=3.8 + mamba install -c conda-forge pyinstaller pip install path - name: Run build shell: bash --login {0} run: | - conda info + mamba info pyinstaller cq-cli_pyinstaller.spec ${{ github.event.inputs.type }} - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v7 with: name: cq-cli-Linux-x86_64 path: dist build-macos: runs-on: macos-latest steps: - - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: actions/checkout@v6 + - uses: conda-incubator/setup-miniconda@v3 with: miniconda-version: "latest" auto-update-conda: true - python-version: 3.8 + python-version: 3.11 activate-environment: test - name: Install CadQuery and pyinstaller shell: bash --login {0} @@ -57,33 +58,35 @@ jobs: run: | conda info pyinstaller cq-cli_pyinstaller.spec ${{ github.event.inputs.type }} - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v7 with: name: cq-cli-MacOS path: dist build-windows: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: actions/checkout@v6 + - uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: "latest" - auto-update-conda: true - python-version: 3.8 + miniforge-version: latest + # auto-update-conda: true + python-version: 3.11 activate-environment: test - name: Install CadQuery and pyinstaller shell: pwsh run: | - conda info - conda install -c cadquery -c conda-forge cadquery=master python=3.8 libnetcdf=4.7.4 - conda install -c conda-forge pyinstaller + mamba info + mamba install -c cadquery -c conda-forge cadquery=master python=3.8 libnetcdf=4.7.4 ocp + mamba install -c conda-forge pyinstaller pip install path - name: Run build shell: pwsh run: | - conda info + dir C:\Mini* + Get-ChildItem -Path C:\Miniconda3\envs\test\ -Filter OCP* -Recurse + mamba info pyinstaller cq-cli_pyinstaller.spec ${{ github.event.inputs.type }} - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v7 with: name: cq-cli-Windows path: dist diff --git a/.github/workflows/pyinstaller.yml b/.github/workflows/pyinstaller.yml new file mode 100644 index 0000000..4375c5f --- /dev/null +++ b/.github/workflows/pyinstaller.yml @@ -0,0 +1,75 @@ +name: pyinstaller-build +on: + workflow_dispatch: + inputs: + type: + description: 'Whether to build a single file (onefile) or directory (dir) dist' + required: true + default: 'dir' +jobs: + build-linux: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Install dependencies + run: | + pip install --upgrade pip + pip --version + pip install -e . + pip install pyinstaller + pip install path + - name: Run PyInstaller build + run: | + pyinstaller pyinstaller.spec ${{ github.event.inputs.type }} + - uses: actions/upload-artifact@v7 + with: + name: cq-cli-Linux-x86_64 + path: dist + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Install dependencies + run: | + pip install --upgrade pip + pip --version + pip install -e . + pip install pyinstaller + pip install path + - name: Run PyInstaller build + run: | + pyinstaller pyinstaller.spec ${{ github.event.inputs.type }} + - uses: actions/upload-artifact@v7 + with: + name: cq-cli-MacOS + path: dist + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Install dependencies + run: | + pip install --upgrade pip + pip --version + pip install -e . + pip install pyinstaller + pip install path + - name: Run build + run: | + # Get-ChildItem -Path C:\ -Filter libcasadi.dll -Recurse + # CPATH=$pythonLocation/include/python${{ matrix.python-version }}m + # echo "CPATH=$CPATH" >> $GITHUB_ENV + pyinstaller pyinstaller.spec ${{ github.event.inputs.type }} + - uses: actions/upload-artifact@v7 + with: + name: cq-cli-Windows + path: dist diff --git a/.github/workflows/test_freecad.yml b/.github/workflows/test_freecad.yml new file mode 100644 index 0000000..e2647b5 --- /dev/null +++ b/.github/workflows/test_freecad.yml @@ -0,0 +1,23 @@ +name: Test FreeCAD + +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v6 + + - name: Install and Test + run: | + conda create -y -n freecad -c conda-forge python=3.11 freecad=0.21.2 cadquery + conda init bash + source /usr/share/miniconda/bin/activate + conda activate freecad + python -m pip install --upgrade pip + pip install -e . --no-deps + pip install cadquery_freecad_import_plugin + pip install -e .[dev] + pip install pytest-xvfb + python -m pytest -v tests/test_freecad.py \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..cfd0da5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: tests +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + run-pytest: + strategy: + fail-fast: false + matrix: + python-version: ["3.11"] + os: [ubuntu-22.04] # 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-22.04' + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install CadQuery and pytest + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install -e .[dev] + - name: Run tests + run: | + python -m pytest -v --ignore=tests/test_freecad.py diff --git a/.gitignore b/.gitignore index 80e953f..e0e64f2 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +# FreeCAD related files +updated_part* +.DS_Store diff --git a/README.md b/README.md index 4a5ae97..9b171e0 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,212 @@ # cq-cli -[![tests](https://github.com/CadQuery/cq-cli/workflows/tests/badge.svg)](https://github.com/CadQuery/cq-cli/actions) +[![tests](/actions/workflows/tests.yml/badge.svg)](/actions) ## Contents -* [Introduction](https://github.com/CadQuery/cq-cli#introduction) -* [Getting Help](https://github.com/CadQuery/cq-cli#getting_help) -* [Installation](https://github.com/CadQuery/cq-cli#installation) -* [Usage](https://github.com/CadQuery/cq-cli#usage) -* [Examples](https://github.com/CadQuery/cq-cli#examples) -* [Drawbacks](https://github.com/CadQuery/cq-cli#drawbacks) -* [Contributing](https://github.com/CadQuery/cq-cli#contributing) +* [Introduction](#introduction) +* [Getting Help](#getting-help) +* [Installation](#installation) +* [Usage](#usage) +* [Examples](#examples) +* [Contributing](#contributing) ## Introduction -***Please Note*** cq-cli is in alpha currently. Major features may be broken, and the application may change a lot before a full release. +cq-cli is a Command Line Interface for executing CadQuery scripts and converting their output to another format. It uses a plugin system where "codecs" can be placed in the `cqcodecs` directory and will be automatically loaded and used if a user selects that codec from the command line. -cq-cli is a Command Line Interface for executing CadQuery scripts and converting their output to another format. It uses a plugin system where "codecs" can be placed in the cqcodecs directory and will be automatically loaded and used if a user selects that codec from the command line. +Input and output files can be specified via arguments, but cq-cli also supports stdin, stdout, and stderr streams so that it can be used in a pipeline. -cq-cli is designed to be a batteries-included distribution, although this approach comes with some drawbacks as listed below. However, to have a CadQuery conversion utility that requires no installation and no Anaconda environment can be useful in certain cases. +**Requires Python 3.11 or later.** Linux, macOS, and Windows are supported, though some features may behave differently on Windows. -It is possible to specify input and output files using arguments, but cq-cli also allows the use to the stdin, stdout and stderr streams so that it can be used in a pipeline. +## Installation -Linux, MacOS and Windows are supported, but some features may be used differently or may not work the same in Windows. +### uv (preferred) ⭐️ -## Getting Help +[uv](https://docs.astral.sh/uv/) is the recommended way to install and run cq-cli. It handles Python version management and virtual environments automatically. -In addition to opening an issue on this repo, there is a [CadQuery Discord channel](https://discord.gg/qz3uAdF) and a [Google Group](https://groups.google.com/g/cadquery) that you can join to ask for help getting started with cq-cli. +``` +uv venv --python 3.11 +source .venv/bin/activate # Windows: .venv\Scripts\activate +uv sync +``` -## Installation +Once complete, run cq-cli with: +``` +cq-cli --help +``` -Download a binary distribution that is appropriate for your operating system from the [latest release](https://github.com/CadQuery/cq-cli/releases/tag/v0.1.0-alpha), extract the zip file, and make sure to put the cq-cli binary in the PATH. Then the CLI can be invoked as `cq-cli` (`cq-cli.exe` on Windows). +### pip -If installing on Windows, the [latest redistributable for Visual Studio](https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads) will need to be installed. +It is strongly recommended to use a Python virtual environment when installing via pip. -If a development installation is desired, see the [Contributing](#contributing) section below. +To install the latest release from PyPI, use the following command: +``` + pip install cadquery-cli +``` +**Note:** There was a naming collision on PyPI which required the package name to be `cadquery-cli` rather than `cq-cli`. The command name stays the same after installation though. + +To install the latest development version instead of the stable release, use the following command: + +``` +pip install git+https://github.com/CadQuery/cq-cli.git +``` + +Once the installation is complete, the CLI can be invoked as: +``` +cq-cli --help +``` +or: +``` +python -m cq_cli.main --help +``` ## Usage -usage: cq-cli.py [-h] [--codec CODEC] [--infile INFILE] [--outfile OUTFILE] [--errfile ERRFILE] [--params PARAMS] [--outputopts OPTS] [--validate VALIDATE] +``` +cq-cli [-h] [--codec CODEC] [--infile INFILE] [--outfile OUTFILE] + [--errfile ERRFILE] [--params PARAMS] [--outputopts OPTS] + [--getparams GETPARAMS] [--validate VALIDATE] [--expression EXPRESSION] +``` -Command line utility for converting CadQuery script output to various other output formats. +Command line utility for converting CadQuery script output to various output formats. -optional arguments: -* -h, --help Show this help message and exit -* --codec CODEC The codec to use when converting the CadQuery output. Must match the name of a codec file in the cqcodecs directory. -* --getparams GETPARAMS -* --infile INFILE The input CadQuery script to convert. -* --outfile OUTFILE File to write the converted CadQuery output to. Prints to stdout if not specified. -* --errfile ERRFILE File to write any errors to. Prints to stderr if not specified. -* --params PARAMS 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; -* --outputopts OPTS 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; -* --validate VALIDATE Setting to true forces the CLI to only parse and validate the script and not produce converted output. +| Argument | Description | +|---|---| +| `-h`, `--help` | Show help message and exit | +| `--codec CODEC` | The codec to use for conversion (e.g. `step`, `stl`, `svg`, `dxf`, `glb`, `gltf`, `threejs`). Can be omitted if `--outfile` has a recognised extension. Multiple codecs can be specified separated by `;` — must match the number of `--outfile` entries. | +| `--infile INFILE` | The input CadQuery script (`.py`) or FreeCAD file (`.fcstd`). Reads from stdin if omitted. | +| `--outfile OUTFILE` | File to write the converted output to. Prints to stdout if omitted. Multiple output files can be specified separated by `;`. | +| `--errfile ERRFILE` | File to write errors to. Prints to stderr if omitted. | +| `--params PARAMS` | Parameters to pass to the script. Accepts: a JSON file path, a JSON string (`{"width":10}`), or a colon/semicolon delimited string (`width:10;height:5;`). | +| `--outputopts OPTS` | Codec-specific options as a colon/semicolon delimited string. e.g. `width:100;height:200;` | +| `--getparams GETPARAMS` | Analyse the script and write parameter metadata as JSON. Pass a file path to write to a file, or `true` to print to stdout. | +| `--validate VALIDATE` | Set to `true` to validate the script without producing output. | +| `--expression EXPRESSION` | A Python expression to evaluate and render (e.g. `my_shape(x=5)`). Useful for rendering a specific part from a file that contains multiple functions. | ## Examples 1. Find out what codecs are available. ``` -./cq-cli.py --codec help +cq-cli --codec help ``` 2. Validate a CadQuery script. ``` -./cq-cli.py --validate true --infile /input/path/script.py +cq-cli --validate true --infile /input/path/script.py ``` 3. Convert a CadQuery script to STEP format and output to stdout. ``` -./cq-cli.py --codec step --infile /input/path/script.py +cq-cli --codec step --infile /input/path/script.py ``` 4. Convert a CadQuery script to STEP format and write to a file. ``` -./cq-cli.py --codec step --infile /input/path/script.py --outfile /output/path/newfile.step +cq-cli --codec step --infile /input/path/script.py --outfile /output/path/newfile.step ``` 5. Convert a CadQuery script and write any errors to a separate file. ``` -./cq-cli.py --codec step --infile /input/path/script.py -errfile /error/path/error.txt +cq-cli --codec step --infile /input/path/script.py --errfile /error/path/error.txt ``` -6. Convert a CadQuery script using the stdin and stdout streams. This example counts the lines in the resulting STEP output as a trivial example. +6. Convert a CadQuery script using stdin and stdout streams. This example counts the lines in the resulting STEP output. ``` -cat /input/path/script.py | cq-cli.py --codec step | wc -l +cat /input/path/script.py | cq-cli --codec step | wc -l ``` -7. Convert a CadQuery script to SVG, passing in output options to influence the resulting image. +7. Let cq-cli infer the codec from the output file extension. ``` -./cq-cli.py --codec svg --infile /input/path/script.py --outfile /output/path/newfile.svg --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;" +cq-cli --infile /input/path/script.py --outfile /output/path/newfile.stl ``` -8. Convert a CadQuery script to STL, passing in output options to change the quality of the resulting STL. Explanation of linear vs angular deflection can be found [here](https://dev.opencascade.org/doc/occt-7.1.0/overview/html/occt_user_guides__modeling_algos.html#occt_modalg_11_2). +8. Convert to multiple output formats in a single invocation. ``` -./cq-cli.py --codec stl --infile /input/path/script.py --outfile /output/path/script.stl --outputopts "linearDeflection:0.3;angularDeflection:0.3" +cq-cli --infile /input/path/script.py --outfile /output/path/model.step;/output/path/model.stl ``` -9. Extract parameter information from the input script. The outfile argument can also be left off to output the parameter JSON to stdout. +9. Convert a CadQuery script to SVG, passing output options to influence the resulting image. ``` -./cq-cli.py --getparams /output/path/params.json --infile /input/path/script.py +cq-cli --codec svg --infile /input/path/script.py --outfile /output/path/newfile.svg --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;" ``` -10. Pass JSON parameter information from a file to be used in the script. +10. Convert a CadQuery script to STL, adjusting mesh quality. Explanation of linear vs angular deflection can be found [here](https://dev.opencascade.org/doc/occt-7.1.0/overview/html/occt_user_guides__modeling_algos.html#occt_modalg_11_2). ``` -./cq-cli.py --codec stl --infile /input/path/script.py --outfile /output/path/output.stl --params /parameter/path/parameters.json +cq-cli --codec stl --infile /input/path/script.py --outfile /output/path/script.stl --outputopts "linearDeflection:0.3;angularDeflection:0.3" ``` -11. Pass JSON parameter data as a string on the command line. +11. Extract parameter information from a script. Omit the file path to print JSON to stdout. ``` -./cq-cli.py --codec stl --infile /input/path/script.py --params "{\"width\":10}" +cq-cli --getparams /output/path/params.json --infile /input/path/script.py +``` +12. Pass JSON parameter information from a file to the script. +``` +cq-cli --codec stl --infile /input/path/script.py --outfile /output/path/output.stl --params /parameter/path/parameters.json +``` +13. Pass JSON parameter data as a string on the command line. +``` +cq-cli --codec stl --infile /input/path/script.py --params "{\"width\":10}" +``` +14. Pass parameters as a colon/semicolon delimited string. +``` +cq-cli --codec stl --infile /input/path/script.py --outfile test.stl --params "width:2;centered:True" +``` +15. Render a specific function from a file using `--expression`. +``` +cq-cli --codec step --infile /input/path/script.py --outfile /output/path/part.step --expression "my_part(x=5)" ``` - -## Drawbacks - -* The file (and directory) size for cq-cli is very large. cq-cli uses pyinstaller to package the binaries for each platform, and must embed all needed dependencies. The OCP and OCCT library dependencies add a minimum of ~270 MB of data on top of the included Python distribution. It is possible that the pyinstaller spec file could be optimized. If you are interested in helping with this, please let us know by opening an issue. -* Startup times for the single binary are relatively slow. If startup and execution time is important to you, consider using the pyinstaller_dir.spec spec file with pyinstaller: `pyinstaller pyinstaller_dir.spec`. ## Contributing -If you want to help improve and expand cq-cli, the following steps should get you up and running with a development setup. There is a [CadQuery Discord channel](https://discord.gg/qz3uAdF) and a [Google Group](https://groups.google.com/g/cadquery) that you can join to ask for help getting started. +### Development Setup -### Anaconda Environment +The recommended way to set up a development environment is with [uv](https://docs.astral.sh/uv/). -A CadQuery Anaconda environment is required to run and build cq-cli via PyInstaller. For those unfamiliar (or uncomfortable) with Anaconda, it is probably best to start by installing Miniconda to a local directory and to avoid running `conda init`. After performing a local directory installation, an Anaconda environment can be activated via the [scripts,bin]/activate scripts. This will help avoid polluting and breaking the local Python installation. +``` +git clone https://github.com/CadQuery/cq-cli.git +cd cq-cli +uv venv --python 3.11 +source .venv/bin/activate # Windows: .venv\Scripts\activate +uv sync --extra dev +``` -Once the conda command is available, it is recommended that users build the environment from the latest master of the cadquery repo. +Alternatively, using pip: ``` -conda create -n cq-cli -conda activate cq-cli -conda install -c cadquery -c conda-forge cadquery=master +git clone https://github.com/CadQuery/cq-cli.git +cd cq-cli +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e .[dev] ``` -### Adding a Codec +Run the test suite: +``` +pytest -v --ignore=tests/test_freecad.py +``` -The codec plugin system is based on naming conventions so that cq-cli knows what codec options to accept from the user. When adding a codec, make sure to place it in the `cqcodecs` directory and follow the naming convention `cq_codec_[your codec name].py`. The `your codec name` part of the filename will automatically be used as the codec name that the user specifies. +### Adding a Codec -A good example to start with when creating your own codec would be `cqcodecs/cq_codec_step.py` as it shows a simple implementation of the codec that relies on CadQuery to do all the heavy lifting. At the very least, your codec needs to have a `convert` function that takes in a [CQGI BuildResult object](https://cadquery.readthedocs.io/en/latest/cqgi.html#cadquery.cqgi.BuildResult) and returns a string representing the converted model(s). As an alternative, cq-cli will pass the output file name, which makes it possible to write the output to the outfile path directly from the codec. If `None` is returned from the `convert` function, cq-cli will assume that the output was written directly to the output file by the codec. +The codec plugin system is based on naming conventions so that cq-cli knows what codec options to accept from the user. When adding a codec, place it in the `cqcodecs` directory and follow the naming convention `cq_codec_[your codec name].py`. The `your codec name` part of the filename will automatically be used as the codec name specified by the user. -For pyinstaller to know about the new dynamically loaded codec, it must be added to the `hiddenimports` array in both cq-cli_onfile_pyinstaller.spec and cq-cli_dir_pyinstaller.spec files. Leave the `.py` off of the name when adding to the array, for instance `codec.cq_codec_step` is the string used for the STEP codec. When only running a codec locally and not contributing it, this step is not required. +A good starting point is [cqcodecs/cq_codec_step.py](src/cq_cli/cqcodecs/cq_codec_step.py), which shows a simple codec implementation that relies on CadQuery to do the heavy lifting. At minimum, your codec needs a `convert` function that accepts a [CQGI BuildResult object](https://cadquery.readthedocs.io/en/latest/cqgi.html#cadquery.cqgi.BuildResult) and returns a string or bytes representing the converted model. If the codec writes the output file directly, return `None` and cq-cli will assume the output was written to disk. ### Adding a Codec Test -A test is required when adding a codec to cq-cli, but is easy to add. Add a file named `test_[your codec name]_codec.py` in the tests directory, and add the test to it. `test_step_codec.py` would be a good template to base any new tests off of. +A test is required when adding a codec to cq-cli. Add a file named `test_[your codec name]_codec.py` in the `tests` directory. [tests/test_step_codec.py](tests/test_step_codec.py) is a good template. ### Exit Codes -Applications can return a non-zero exit code to let the user know what went wrong. Below is a listing of the exit codes for cq-cli and what they mean. +| Code | Meaning | +|---|---| +| **0** | Operation completed successfully. | +| **1** | The CadQuery script could not be read from `--infile`. | +| **2** | Usage error — incorrect or insufficient arguments. | +| **3** | No valid codec was provided or could be inferred. | +| **100** | Error while running the CadQuery script (build error, possibly from OCCT). | +| **200** | Error while running the conversion codec. | -* **0:** Operation was successful with no errors detected. -* **1:** A CadQuery script could not be read from the given `infile`. -* **2:** There was a usage error with the parameters that were passed to cq-cli (too few parameters, not the correct ones, etc). -* **3:** A codec for converting the results of the CadQuery script was not provided. -* **100:** There was an unknown error while running the CadQuery script and obtaining a result (build error, possibly from OCCT). -* **200:** There was an unknown error while running a codec to convert the results of the CadQuery script. +--- -### pyinstaller +### Github Workflows -If building cq-cli to run as a stand-alone app is required, there are two modes to build it in: `onefile` and `dir` (directory). onefile mode creates a single file for the app, which is easy to distribute but takes longer to start up run on each execution. dir mode creates a directory with numerous dependency files in it, including the cq-cli binary file, and starts up faster than the single file. However, the directory distribution can take up more than twice the disk space and can be messier to distribute. A PyInstaller spec file has been provided for both modes, and selecting between them only requires the addition of a command line argument. The commands to build in each type of mode are outlined below. - -There are a few packages, including PyInstaller, must be installed via conda or pip before executing either of the `pyinstaller` commands below. -``` -pip install pyinstaller -pip install path -``` -The output for both of the commands will be in the `dist` directory. If the mode argument is left off, `onefile` is assumed. +This repository has five GitHub Actions workflows in \`.github/workflows\`: -#### pyinstaller onefile -``` -pyinstaller cq-cli_pyinstaller.spec onefile -``` +**CI/CD** +- `lint.yml\`: Runs Black formatting checks (currently on Python 3.13). +- `tests.yml\`: Runs the main test suite with pytest (currently on Python 3.11). +- `test\_freecad.yml\`: Runs FreeCAD-specific integration tests in a conda environment. -#### pyinstaller directory -``` -pyinstaller cq-cli_pyinstaller.spec dir -``` +**Builds per OS - WIP** +- `pyinstaller.yml\`: Manually triggered PyInstaller builds for Linux, macOS, and Windows. +- `pyinstaller-builds-actions.yml\`: Alternate/manual conda-based cross-platform PyInstaller build workflow. diff --git a/TECH_README.md b/TECH_README.md new file mode 100644 index 0000000..370db37 --- /dev/null +++ b/TECH_README.md @@ -0,0 +1,61 @@ +# CadQuery CLI (cq-cli) - Project Overview + +`cq-cli` is a Command Line Interface for executing CadQuery scripts and converting their output to various formats (STEP, STL, DXF, SVG, GLB, GLTF, ThreeJS). It is designed to be used in automation pipelines and supports stdin/stdout streams. + +## Architecture + +The project is built around the **CadQuery Gateway Interface (CQGI)**. +- **Entry Point:** `src/cq_cli/main.py` handles argument parsing, script loading, and coordination between CQGI and codecs. +- **Plugin System:** Codecs are dynamically loaded from `src/cq_cli/cqcodecs/` by `loader.py`. Any file matching `cq_codec_*.py` is treated as a codec. +- **FreeCAD Support:** Integrates `cadquery_freecad_import_plugin` to handle `.fcstd` files. + +## Tech Stack +- **Language:** Python 3.11+ +- **Core Library:** [CadQuery](https://github.com/CadQuery/cadquery) +- **Environment Management:** `uv` (preferred) +- **Build Tool:** PyInstaller (for standalone binaries) +- **Testing:** `pytest` + +## Key Commands + +### Development +- **Install Dependencies:** `uv sync` +- **Run CLI (Development):** `python -m cq_cli.main --help` +- **Run Tests:** `pytest` +- **Linting:** `black==26.3.1`, `click==8.3.1` (dev dependencies in `pyproject.toml`) + +### Usage Examples +- **Convert to STEP:** `cq-cli --codec step --infile model.py --outfile model.step` +- **Auto-detect Codec:** `cq-cli --infile model.py --outfile model.stl` +- **Extract Parameters:** `cq-cli --getparams true --infile model.py` +- **Pass Parameters:** `cq-cli --params "width:10;height:20" --infile model.py` +- **Evaluate Expression:** `cq-cli --expression "my_part(10)" --infile models.py` + +### Building +- **PyInstaller (One-file):** `pyinstaller cq-cli_pyinstaller.spec onefile` +- **PyInstaller (Directory):** `pyinstaller cq-cli_pyinstaller.spec dir` + +## Development Conventions + +### Adding a New Codec +1. Create `src/cq_cli/cqcodecs/cq_codec_[name].py`. +2. Implement a `convert` function: + ```python + def convert(build_result, outfile, errfile, output_opts): + # build_result is a cqgi.BuildResult + # Return string/bytes for writing to outfile (or stdout) + # Return None if the codec writes directly to outfile + ``` +3. Add the new codec to `hiddenimports` in `cq-cli_pyinstaller.spec` for standalone builds. +4. Add a test in `tests/test_[name]_codec.py`. + +### Exit Codes +- **0:** Success +- **1:** Input file read error +- **2:** Usage/Argument error +- **3:** Missing/Invalid codec +- **100:** CadQuery build error (script execution failure) +- **200:** Codec conversion error + +## Testing +Tests are located in the `tests/` directory and use `pytest`. Many tests rely on `tests/test_helpers.py` for CLI invocation and `tests/testdata/` for sample scripts. diff --git a/cq-cli.py b/cq-cli.py deleted file mode 100755 index bdd5f9a..0000000 --- a/cq-cli.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import argparse -import cadquery as cq -from cadquery import cqgi -import fileinput -import traceback -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 - try: - # Do the CQGI handling of the script here and, if successful, pass the build result to the codec - cqModel = cqgi.parse(script_str) - build_result = cqModel.build(params) - - # 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) - else: - return build_result - except Exception: - out_tb = traceback.format_exc() - - # If there was an error file specified write to that, otherwise send it to stderr - if errfile != None: - with open(errfile, 'w') as file: - file.write(str(out_tb)) - else: - print(str(out_tb), file=sys.stderr) - - # Let the caller know what happened - sys.exit(100) - - # 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. - """ - script_str = None - - # See whether to ingest a file or accept text from stdin - if infile == None: - # Do not output to stdout if that is where our conversion output is going - if outfile != None: - print("No input file specified, assuming stdin.") - else: - # Make sure the infile exists - if not os.path.isfile(infile): - if errfile == None: - print("infile does not exist.", file=sys.stderr) - else: - with open(errfile, 'w') as file: - file.write("Argument error: infile does not exist.") - - return None - - # If there was an infile specified, read the contents of it, otherwise read from stdin - if infile == None: - # Grab the string from stdin - script_str = sys.stdin.read() - else: - with open(infile, 'r') as file: - script_str = file.read() - - return script_str - - -def set_pythonpath_for_infile(infile): - """ - Sets the PYTHONPATH environment variable to include the location of the infile. - """ - - # If the infile is none we are reading from stdin and there is nothing to set - if infile == None: - return - - # Make sure that any user-created modules are found - sys.path.append(os.path.abspath(os.path.join(infile, os.pardir))) - - -def get_params_from_file(param_json_path, errfile): - """ - Loads JSON parameters in a file into a Python dictionary. - """ - param_dict = None - - # 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: - params_json = file.read() - param_dict_array = json.loads(params_json) - - # Load the array of parameters into the single JSON structure CQGI is expecting - param_dict = {} - - # Account for parameters either being in an array or in a dict of their own - if type(param_dict_array) == list: - for p in param_dict_array: - param_dict[p["name"]] = p["initial"] - elif type(param_dict_array) == dict: - for key in param_dict_array: - 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) - else: - with open(errfile, 'w') as file: - file.write("Argument error: Parameter file does not exist, default parameters will be used.") - - return param_dict - - -def main(): - infile = None - outfile = None - errfile = None - codec = None - codec_module = None - params = {} - output_opts = {} - - # Find the codecs that have been added. - 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', dest='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', dest='getparams', help='Analyzes the script and returns a JSON string with the parameter information.') - parser.add_argument('--infile', dest='infile', help='The input CadQuery script to convert.') - parser.add_argument('--outfile', dest='outfile', help='File to write the converted CadQuery output to. Prints to stdout if not specified.') - parser.add_argument('--errfile', dest='errfile', help='File to write any errors to. Prints to stderr if not specified.') - parser.add_argument('--params', dest='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', dest='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.getparams == None and args.codec == None: - parser.print_help(sys.stderr) - sys.exit(2) - - # - # Outfile handing - # - # See whether to output to a file or stdout - if args.outfile != None: - outfile = args.outfile - - # - # Errfile handling - # - # See whether to output errors to a file or stderr - if args.errfile != None: - errfile = args.errfile - - # - # Validation handling - # - # If the user wants to validate, do that and exit - if args.validate == 'true': - script_str = get_script_from_infile(args.infile, outfile, errfile) - 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) - - build_result = build_and_parse(script_str, params, errfile) - - # Double-check that the build was a success - 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') - else: - print('validation_success') - - return 0 - - # - # Parameter analysis - # - # Analyzes the parameters that are available in the script. - # - if args.getparams != None: - # Array of dictionaries that holds the parameter data - params = [] - - # Load the script string - script_str = get_script_from_infile(args.infile, outfile, errfile) - 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) - - # A representation of the CQ script with all the metadata attached - cq_model = None - try: - cq_model = cqgi.parse(script_str) - except Exception as err: - print("Script error: " + str(err), file=sys.stderr) - - # Allows us to present parameters to users later that they can alter - parameters = cq_model.metadata.parameters - - # Step through all the parameters and add them to the array of dictionaries - for param in parameters.values(): - new_dict = {} - - # Return the data type of the parameter, trying to match conventions set by other platforms - if param.varType.__name__ == "NumberParameterType": - new_dict["type"] = "number" - elif param.varType.__name__ == "StringParameterType": - new_dict["type"] = "string" - elif param.varType.__name__ == "BooleanParameterType": - new_dict["type"] = "boolean" - - # Save the name of the parameter - new_dict["name"] = param.name - - # If there is a description, save it - if param.desc: - new_dict["caption"] = param.desc - - # If there is an initial value, save it - if param.default_value: - new_dict["initial"] = param.default_value - - # If there are values set for valid values via describe_parameter(), add those - if param.valid_values: - new_dict["min"] = param.valid_values[0] - new_dict["max"] = param.valid_values[-1] - new_dict["step"] = new_dict["max"] - new_dict["min"] - - # Ensure that the step is larger than 0 - if new_dict["step"] <= 0: - new_dict["step"] = 1 - - params.append(new_dict) - - # Write the converted output to the appropriate place based on the command line arguments - if args.getparams == 'true': - print(json.dumps(params)) - else: - with open(args.getparams, 'w') as file: - file.write(json.dumps(params)) - - # Check to see if the user only cared about getting the params - if args.codec == None: - return 0 - - # - # Codec handling - # - # Save the requested codec for later - codec = args.codec - - # If the user has not supplied a codec, they need to be validating the script - if (codec == 'help' or codec == None) and (args.validate == None or args.validate == 'false'): - print("Please specify a codec. You have the following to choose from:") - for key in loaded_codecs: - print(key.replace('cq_codec_', '')) - sys.exit(3) - - for key in loaded_codecs: - # Check to make sure that the requested codec exists - if codec in key: - codec_module = loaded_codecs[key] - - # - # Infile handling - # - infile = args.infile - - # 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) - - # Set the PYTHONPATH variable to the current directory to allow module loading - set_pythonpath_for_infile(args.infile) - - # - # Parameter handling - # - # 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] == ':': - # Load the parameters dictionary from the file - file_params = get_params_from_file(args.params, errfile) - - # Make sure we got parameters back before we try to pass it to CQGI - if file_params != None: - params = file_params - elif args.params.startswith("{"): - # Convert the JSON string passed from the user to a Python dictionary - params = json.loads(args.params) - else: - # Convert the string of parameters into a params dictionary - groups = args.params.split(';') - for group in groups: - param_parts = group.split(':') - # Protect against a trailing semi-colon - if len(param_parts) == 2: - params[param_parts[0]] = param_parts[1] - - # - # Output options handling - # - # 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(';') - for group in groups: - opt_parts = group.split(':') - # Protect against a trailing semi-colon - if len(opt_parts) == 2: - op1 = opt_parts[1] - - # Handle the option data types properly - if op1 == "True" or op1 == "False": - op = opt_parts[1] == "True" - elif op1[:1] == "(": - op = tuple(map(float, opt_parts[1].replace("(", "").replace(")", "").split(','))) - elif "." in op1: - op = float(opt_parts[1]) - else: - op = int(opt_parts[1]) - - output_opts[opt_parts[0]] = op - - # - # Parse and build the script. - # - build_result = None - try: - build_result = build_and_parse(script_str, params, errfile) - - # If None was returned, it means the build failed and the exception has already been reported - if build_result == None: - sys.exit(100) - except Exception as err: - # Write the file to the appropriate place based on what the user specified - if errfile == None: - print("build_and_parse error: " + str(err), file=sys.stderr) - else: - with open(errfile, 'w') as file: - file.write(err) - sys.exit(100) - - # - # Final build - # - # Build, parse and let the selected codec convert the CQ output - try: - # Use the codec plugin to do the conversion - converted = codec_module.convert(build_result, outfile, errfile, output_opts) - - # If converted is None, assume that the output was written to file directly by the codec - if converted != None: - # Write the converted output to the appropriate place based on the command line arguments - if outfile == None: - print(converted) - else: - with open(outfile, 'w') as file: - file.write(converted) - - except Exception: - out_tb = traceback.format_exc() - - # Send the error to wherever the user requested - if errfile == None: - print("Conversion codec error: " + str(out_tb), file=sys.stderr) - else: - with open(errfile, 'w') as file: - file.write(str(out_tb)) - - sys.exit(200) - -if __name__ == "__main__": - main() diff --git a/cq-cli_pyinstaller.spec b/cq-cli_pyinstaller.spec index 8f42f80..ef1d577 100644 --- a/cq-cli_pyinstaller.spec +++ b/cq-cli_pyinstaller.spec @@ -25,7 +25,7 @@ elif sys.platform == 'win32': # Dynamically find all the modules in the cqcodecs directory hidden_imports = [] -file_list = glob.glob('.' + os.path.sep + 'cqcodecs' + os.path.sep + 'cq_codec_*.py') +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", "") @@ -45,13 +45,13 @@ hidden_imports.append('vtkmodules.all') hidden_imports_numpy = collect_submodules('numpy') hidden_imports = hidden_imports + hidden_imports_numpy -a = Analysis(['cq-cli.py'], +a = Analysis(['src/cq_cli/main.py'], pathex=['.'], binaries=[ ocp_path ], datas=[ - (os.path.join(os.path.dirname(os.path.realpath('__file__')), 'cqcodecs'), 'cqcodecs') + (os.path.join(os.path.dirname(os.path.realpath('__file__')), 'src', 'cq_cli', 'cqcodecs'), 'cqcodecs') ], hiddenimports=hidden_imports, hookspath=[], diff --git a/cqcodecs/cq_codec_svg.py b/cqcodecs/cq_codec_svg.py deleted file mode 100644 index cf9a5e5..0000000 --- a/cqcodecs/cq_codec_svg.py +++ /dev/null @@ -1,20 +0,0 @@ -import os, tempfile -from cadquery import exporters -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() - temp_file = os.path.join(temp_dir, "temp_svg.svg") - - # 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) - - # Read the STEP output back in - with open(temp_file, 'r') as file: - step_str = file.read() - - return step_str \ No newline at end of file diff --git a/pyinstaller.spec b/pyinstaller.spec new file mode 100644 index 0000000..1ae9ea3 --- /dev/null +++ b/pyinstaller.spec @@ -0,0 +1,115 @@ +# -*- 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('casadi') +hidden_imports.append('ipopt') +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 + +if sys.platform == 'win32': + # print(os.path.join(os.environ['CPATH'])) + dlls = [('C:\\hostedtoolcache\\windows\\Python\\3.10.11\\x64\\Lib\\site-packages\\casadi\\libcasadi.dll', '.'), + ('C:\\hostedtoolcache\\windows\\Python\\3.10.11\\x64\\Lib\\site-packages\\casadi\\libcasadi_nlpsol_ipopt.dll', '.'), + #('C:\\hostedtoolcache\\windows\\Python\\3.10.11\\x64\\Lib\\site-packages\\casadi\\libgfortran-3.dll', '.'), + ('C:\\hostedtoolcache\\windows\\Python\\3.10.11\\x64\\Lib\\site-packages\\casadi\\libquadmath-0.dll', '.')] +else: + dlls = [] + +a = Analysis(['src/cq_cli/main.py'], + pathex=['.'], + binaries=dlls, + 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 new file mode 100644 index 0000000..91056c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "cadquery-cli" +version = "2.5.0" +license = {file = "LICENSE"} +authors = [ + { name="Jeremy Wright" }, +] +description = "Command Line Interface for executing CadQuery scripts and converting their output to another format." +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +dependencies = [ + "cadquery", + "cadquery_freecad_import_plugin" +] + +[project.scripts] +cq-cli = "cq_cli.main:main" + +[project.optional-dependencies] +dev = [ + "pytest", + "black==26.3.1", + "click==8.3.1" +] + +[project.urls] +"Homepage" = "https://github.com/CadQuery/cq-cli" +"Bug Tracker" = "https://github.com/CadQuery/cq-cli/issues" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/cq_cli"] 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/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/src/cq_cli/cqcodecs/cq_codec_dxf.py b/src/cq_cli/cqcodecs/cq_codec_dxf.py new file mode 100644 index 0000000..84e4f11 --- /dev/null +++ b/src/cq_cli/cqcodecs/cq_codec_dxf.py @@ -0,0 +1,25 @@ +import os, tempfile +from cadquery import exporters +import cq_cli.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() + temp_file = os.path.join(temp_dir, "temp_dxf.dxf") + + # The exporters will add extra output that we do not want, so suppress it + with helpers.suppress_stdout_stderr(): + # Put the DXF output into the temp file + exporters.export( + build_result.results[0].shape, + temp_file, + exporters.ExportTypes.DXF, + opt=output_opts, + ) + + # Read the DXF output back in + with open(temp_file, "r") as file: + dxf_str = file.read() + + return dxf_str diff --git a/src/cq_cli/cqcodecs/cq_codec_glb.py b/src/cq_cli/cqcodecs/cq_codec_glb.py new file mode 100644 index 0000000..f60b831 --- /dev/null +++ b/src/cq_cli/cqcodecs/cq_codec_glb.py @@ -0,0 +1,25 @@ +import os, tempfile +import cq_cli.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() + temp_file = os.path.join(temp_dir, "temp_glb.glb") + + # The exporters will add extra output that we do not want, so suppress it + with helpers.suppress_stdout_stderr(): + # Put the GLB output into the temp file + # Check to see if we are dealing with an assembly or a single object + if type(build_result.first_result.shape).__name__ == "Assembly": + build_result.first_result.shape.save(temp_file, binary=True) + else: + raise ValueError( + "GLB export is only available for CadQuery assemblies at this time" + ) + + # Read the GLB output back in + with open(temp_file, "rb") as file: + glb_data = file.read() + + return glb_data diff --git a/src/cq_cli/cqcodecs/cq_codec_gltf.py b/src/cq_cli/cqcodecs/cq_codec_gltf.py new file mode 100644 index 0000000..3274be9 --- /dev/null +++ b/src/cq_cli/cqcodecs/cq_codec_gltf.py @@ -0,0 +1,25 @@ +import os, tempfile +import cq_cli.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() + temp_file = os.path.join(temp_dir, "temp_gltf.gltf") + + # The exporters will add extra output that we do not want, so suppress it + with helpers.suppress_stdout_stderr(): + # Put the GLTF output into the temp file + # Check to see if we are dealing with an assembly or a single object + 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" + ) + + # Read the GLTF output back in + with open(temp_file, "r") as file: + gltf_str = file.read() + + return gltf_str diff --git a/cqcodecs/cq_codec_step.py b/src/cq_cli/cqcodecs/cq_codec_step.py similarity index 51% rename from cqcodecs/cq_codec_step.py rename to src/cq_cli/cqcodecs/cq_codec_step.py index 6bdec53..9f74a65 100644 --- a/cqcodecs/cq_codec_step.py +++ b/src/cq_cli/cqcodecs/cq_codec_step.py @@ -1,7 +1,8 @@ import os, tempfile from cadquery import exporters import cadquery as cq -import cqcodecs.codec_helpers as helpers +import cq_cli.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 @@ -10,11 +11,19 @@ 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) + # There should be a shape in the build results + shape = build_result.results[0].shape + + # assembly or a single object? + if type(shape).__name__ == "Assembly": + # use assembly save method + shape.save(temp_file) + else: + # Put the STEP output into the temp file + exporters.export(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 51% rename from cqcodecs/cq_codec_stl.py rename to src/cq_cli/cqcodecs/cq_codec_stl.py index 126ed1c..b294d4e 100644 --- a/cqcodecs/cq_codec_stl.py +++ b/src/cq_cli/cqcodecs/cq_codec_stl.py @@ -1,7 +1,8 @@ import os, tempfile from cadquery import exporters import cadquery as cq -import cqcodecs.codec_helpers as helpers +import cq_cli.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 @@ -12,18 +13,31 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): angularDeflection = 0.1 # If the user has provided the deflection settings, use them - if "linearDeflection" in output_opts: + if output_opts and "linearDeflection" in output_opts: linearDeflection = output_opts["linearDeflection"] - if "angularDeflection" in output_opts: + if output_opts and "angularDeflection" in output_opts: angularDeflection = output_opts["angularDeflection"] + use_ascii = True + if output_opts and "binary" in output_opts: + use_ascii = not output_opts["binary"] + # The exporters will add extra output that we do not want, so suppress it with helpers.suppress_stdout_stderr(): + # There should be a shape in the build results + result = build_result.results[0].shape + + # If the build result is an assembly, we have to make it a compound before trying to export it as SVG + if type(result).__name__ == "Assembly": + result = result.toCompound() + else: + result = result.val() + # Put the STL output into the temp file - build_result.results[0].shape.val().exportStl(temp_file, linearDeflection, angularDeflection, True) + result.exportStl(temp_file, linearDeflection, angularDeflection, use_ascii) # Read the STL output back in - with open(temp_file, 'r') as file: + with open(temp_file, "rb") as file: stl_str = file.read() return stl_str diff --git a/src/cq_cli/cqcodecs/cq_codec_svg.py b/src/cq_cli/cqcodecs/cq_codec_svg.py new file mode 100644 index 0000000..7a9aff4 --- /dev/null +++ b/src/cq_cli/cqcodecs/cq_codec_svg.py @@ -0,0 +1,32 @@ +import os, tempfile +from cadquery import exporters +import cq_cli.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() + temp_file = os.path.join(temp_dir, "temp_svg.svg") + + # The exporters will add extra output that we do not want, so suppress it + with helpers.suppress_stdout_stderr(): + # There should be a shape in the build results + result = build_result.results[0].shape + + # If the build result is an assembly, we have to make it a compound before trying to export it as SVG + if type(result).__name__ == "Assembly": + result = result.toCompound() + + # Put the STEP output into the temp file + exporters.export( + result, + temp_file, + exporters.ExportTypes.SVG, + opt=output_opts, + ) + + # Read the STEP output back in + with open(temp_file, "r") as file: + step_str = file.read() + + return step_str diff --git a/cqcodecs/cq_codec_threejs.py b/src/cq_cli/cqcodecs/cq_codec_threejs.py similarity index 73% rename from cqcodecs/cq_codec_threejs.py rename to src/cq_cli/cqcodecs/cq_codec_threejs.py index c547d27..15c61bd 100644 --- a/cqcodecs/cq_codec_threejs.py +++ b/src/cq_cli/cqcodecs/cq_codec_threejs.py @@ -1,7 +1,8 @@ import os, tempfile from cadquery import exporters import cadquery as cq -import cqcodecs.codec_helpers as helpers +import cq_cli.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 @@ -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 62% rename from cqcodecs/loader.py rename to src/cq_cli/cqcodecs/loader.py index feb5942..d6d962d 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_'): - cq_codecs[name] = importlib.import_module("cqcodecs." + name) + if name.startswith("cq_codec_"): + cq_codecs[name] = importlib.import_module("cq_cli.cqcodecs." + name) - return cq_codecs \ No newline at end of file + return cq_codecs diff --git a/src/cq_cli/main.py b/src/cq_cli/main.py new file mode 100755 index 0000000..8a09c69 --- /dev/null +++ b/src/cq_cli/main.py @@ -0,0 +1,596 @@ +#!/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 + +# Add parent directory to path so that `import cq_cli.* resolves correctly. +sys.path.append(os.path.dirname(__file__) + "/..") + +import argparse +import cadquery as cq +from cadquery import cqgi +import fileinput +import traceback +import json +from cq_cli.cqcodecs import loader + + +def handle_freecad_file(file_path, params=None): + """ + Wrapper method that takes care of importing a FreeCAD file and applying parameters to it. + """ + from cadquery_freecad_import_plugin.plugin import import_freecad_part + + # Construct a build result so that the rest of the code can handle it + build_result = cq.cqgi.BuildResult() + + # Only apply parameters if there are any + if params != None and len(params) > 0: + # Assemble the FreeCAD params + freecad_params = {} + for key in params: + freecad_params[key] = {"value": params[key], "units": "mm"} + + # Import the FreeCAD file using the parametric method + result = import_freecad_part(file_path, freecad_params) + shape_result = cq.cqgi.ShapeResult() + shape_result.shape = result + build_result.results.append(shape_result) + build_result.success = True + else: + # Import the FreeCAD file without applying parameters + result = import_freecad_part(file_path) + shape_result = cq.cqgi.ShapeResult() + shape_result.shape = result + build_result.results.append(shape_result) + build_result.success = True + + return build_result + + +def build_and_parse(script_str, params, errfile, expression): + """ + 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 + try: + # If we have a freecad file, handle it differently + if script_str.lower().endswith(".fcstd"): + build_result = handle_freecad_file(script_str, params) + else: + # Do the CQGI handling of the script here and, if successful, pass the build result to the codec + if expression != None: + script_str += "\nshow_object({expr})".format(expr=expression) + cqModel = cqgi.parse(script_str) + build_result = cqModel.build(params) + + # 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) + else: + return build_result + except Exception: + out_tb = traceback.format_exc() + + # If there was an error file specified write to that, otherwise send it to stderr + if errfile != None: + with open(errfile, "w") as file: + file.write(str(out_tb)) + else: + print(str(out_tb), file=sys.stderr) + + # Let the caller know what happened + sys.exit(100) + + # 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. + """ + script_str = None + + # See whether to ingest a file or accept text from stdin + if infile == None: + # Do not output to stdout if that is where our conversion output is going + if outfile != None: + print("No input file specified, assuming stdin.") + else: + # Make sure the infile exists + if not os.path.isfile(infile): + if errfile == None: + print("infile does not exist.", file=sys.stderr) + else: + with open(errfile, "w") as file: + file.write("Argument error: infile does not exist.") + + return None + + # If there was an infile specified, read the contents of it, otherwise read from stdin + if infile == None: + # Grab the string from stdin + script_str = sys.stdin.read() + elif infile.lower().endswith(".fcstd"): + script_str = infile + else: + with open(infile, "r") as file: + # prepend an assignment for the __file__ variable so the model + # script knows its path and can potentially load resources relative + # to that path. + script_str = f"__file__ = '{os.path.abspath(infile)}'\n" + script_str += file.read() + + return script_str + + +def set_pythonpath_for_infile(infile): + """ + Sets the PYTHONPATH environment variable to include the location of the infile. + """ + + # If the infile is none we are reading from stdin and there is nothing to set + if infile == None: + return + + # Make sure that any user-created modules are found + sys.path.append(os.path.abspath(os.path.join(infile, os.pardir))) + + +def get_params_from_file(param_json_path, errfile): + """ + Loads JSON parameters in a file into a Python dictionary. + """ + param_dict = None + + # 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: + params_json = file.read() + param_dict_array = json.loads(params_json) + + # Load the array of parameters into the single JSON structure CQGI is expecting + param_dict = {} + + # Account for parameters either being in an array or in a dict of their own + if type(param_dict_array) == list: + for p in param_dict_array: + param_dict[p["name"]] = p["initial"] + elif type(param_dict_array) == dict: + for key in param_dict_array: + 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, + ) + else: + with open(errfile, "w") as file: + file.write( + "Argument error: Parameter file does not exist, default parameters will be used." + ) + + return param_dict + + +def main(): + outfile = None + outfiles = None + errfile = None + codec_module = None + codecs = None + active_codecs = None + params = {} + output_opts = {} + + # Find the codecs that have been added. + 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="(REQUIRED) The codec to use when converting the CadQuery output. Must match the name of a codec file in the cqcodecs directory. Multiple codecs can be specified, separated by the colon (;) character. The number of codecs must match the number of output files (outfile parameter).", + ) + 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. Multiple output files can be specified, separated by the colon (;) character. The number of codecs (codec parameter) must match the number of output files.", + ) + 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.add_argument( + "--expression", + help="A python expression (such as `my_shape(x=5)`) to evaluate and render. This allows rendering different models/parts from the same python file.", + ) + + 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." + ) + parser.print_help(sys.stderr) + sys.exit(2) + + # + # Outfile handing + # + # See whether to output to a file or stdout + if args.outfile != None: + outfile = args.outfile + + # Handle the case of multiple outfiles + if ";" in outfile: + outfiles = outfile.split(";") + outfile = outfiles[0] + + # + # Errfile handling + # + # See whether to output errors to a file or stderr + if args.errfile != None: + errfile = args.errfile + + # + # Validation handling + # + # If the user wants to validate, do that and exit + if args.validate == "true": + script_str = get_script_from_infile(args.infile, outfile, errfile) + 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) + + build_result = build_and_parse(script_str, params, errfile, args.expression) + + # Double-check that the build was a success + 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") + else: + print("validation_success") + + return 0 + + # + # Parameter analysis + # + # Analyzes the parameters that are available in the script. + # + if args.getparams != None: + # Array of dictionaries that holds the parameter data + params = [] + + # Load the script string + script_str = get_script_from_infile(args.infile, outfile, errfile) + 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) + + # A representation of the CQ script with all the metadata attached + cq_model = None + try: + cq_model = cqgi.parse(script_str) + except Exception as err: + print("Script error: " + str(err), file=sys.stderr) + + # Allows us to present parameters to users later that they can alter + parameters = cq_model.metadata.parameters + + # Step through all the parameters and add them to the array of dictionaries + for param in parameters.values(): + new_dict = {} + + # Return the data type of the parameter, trying to match conventions set by other platforms + if param.varType.__name__ == "NumberParameterType": + new_dict["type"] = "number" + elif param.varType.__name__ == "StringParameterType": + new_dict["type"] = "string" + elif param.varType.__name__ == "BooleanParameterType": + new_dict["type"] = "boolean" + + # Save the name of the parameter + new_dict["name"] = param.name + + # If there is a description, save it + if param.desc: + new_dict["caption"] = param.desc + + # If there is an initial value, save it + if param.default_value: + new_dict["initial"] = param.default_value + + # If there are values set for valid values via describe_parameter(), add those + if param.valid_values: + new_dict["min"] = param.valid_values[0] + new_dict["max"] = param.valid_values[-1] + new_dict["step"] = new_dict["max"] - new_dict["min"] + + # Ensure that the step is larger than 0 + if new_dict["step"] <= 0: + new_dict["step"] = 1 + + params.append(new_dict) + + # Write the converted output to the appropriate place based on the command line arguments + if args.getparams == "true": + print(json.dumps(params)) + else: + with open(args.getparams, "w") as file: + file.write(json.dumps(params)) + + # Check to see if the user only cared about getting the params + if args.codec == None: + return 0 + + # + # Codec handling + # + # Save the requested codec for later + codec = args.codec + + # Handle multiple output files + if codec != None and ";" in codec: + codecs = codec.split(";") + codec = codecs[0] + + # 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] + if codec_temp != None: + codec_temp = "cq_codec_" + codec_temp + if codec_temp in loaded_codecs: + codec = codec_temp + + # If there are multiple output files, make sure to set the codecs for all of them + if outfiles != None and codecs == None: + codecs = [] + for i in range(len(outfiles)): + codec_temp = outfiles[i].split(".")[-1] + if codec_temp != None: + # Construct the codec module name + codec_temp = "cq_codec_" + codec_temp + + if codec_temp in loaded_codecs: + # The codecs array needs just the short name, not the full module name + codecs.append(codec_temp.replace("cq_codec_", "")) + + # Keep track of the codes that are being actively used + if active_codecs == None: + active_codecs = [] + active_codecs.append(loaded_codecs[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" + ): + print("Please specify a valid codec. You have the following to choose from:") + for key in loaded_codecs: + 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_", "")) + sys.exit(3) + + for key in loaded_codecs: + # Check to make sure that the requested codec exists + if codec in key: + codec_module = loaded_codecs[key] + + # If codec_module is still None, the user specified an invalid codec name + if codec_module is None: + print("Please specify a valid codec. You have the following to choose from:") + for key in loaded_codecs: + print(key.replace("cq_codec_", "")) + sys.exit(3) + + # Handle there being multiple codecs + if codecs != None: + for cur_codec in codecs: + for key in loaded_codecs: + # Check to make sure that the requested codec exists + if cur_codec in key: + if active_codecs == None: + active_codecs = [] + active_codecs.append(loaded_codecs["cq_codec_" + cur_codec]) + + # + # Infile handling + # + infile = args.infile + + # 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) + + # Set the PYTHONPATH variable to the current directory to allow module loading + set_pythonpath_for_infile(args.infile) + + # + # Parameter handling + # + # 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 (len(args.params) >= 2 and args.params[1] == ":") + ): + # Load the parameters dictionary from the file + file_params = get_params_from_file(args.params, errfile) + + # Make sure we got parameters back before we try to pass it to CQGI + if file_params != None: + params = file_params + elif args.params.startswith("{"): + # Convert the JSON string passed from the user to a Python dictionary + params = json.loads(args.params) + else: + # Convert the string of parameters into a params dictionary + groups = args.params.split(";") + for group in groups: + param_parts = group.split(":") + # Protect against a trailing semi-colon + if len(param_parts) == 2: + params[param_parts[0]] = param_parts[1] + + # + # Output options handling + # + # 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(";") + for group in groups: + opt_parts = group.split(":") + # Protect against a trailing semi-colon + if len(opt_parts) == 2: + op1 = opt_parts[1] + + # Handle the option data types properly + if op1 == "True" or op1 == "False": + op = opt_parts[1] == "True" + elif op1[:1] == "(": + op = tuple( + map( + float, + opt_parts[1].replace("(", "").replace(")", "").split(","), + ) + ) + elif "." in op1: + op = float(opt_parts[1]) + elif '"' in op1 or "'" in op1: + op = str(opt_parts[1]).strip("\"'") + else: + op = int(opt_parts[1]) + + output_opts[opt_parts[0]] = op + + # + # Parse and build the script. + # + build_result = None + try: + build_result = build_and_parse(script_str, params, errfile, args.expression) + + # If None was returned, it means the build failed and the exception has already been reported + if build_result == None: + sys.exit(100) + except Exception as err: + # Write the file to the appropriate place based on what the user specified + if errfile == None: + print("build_and_parse error: " + str(err), file=sys.stderr) + else: + with open(errfile, "w") as file: + file.write(str(err)) + sys.exit(100) + + # + # Final build + # + # Build, parse and let the selected codec convert the CQ output + try: + # Handle the case of multiple output files + if outfiles == None: + outfiles = [outfile] + + # Step through all of the potential output files + for i in range(len(outfiles)): + outfile = outfiles[i] + if len(outfiles) > 1: + codec_module = active_codecs[i] + + # Use the codec plugin to do the conversion + converted = codec_module.convert( + build_result, outfile, errfile, output_opts + ) + + # If converted is None, assume that the output was written to file directly by the codec + if converted != None: + # Write the converted output to the appropriate place based on the command line arguments + if outfile == None: + if isinstance(converted, (bytes, bytearray)): + sys.stdout.buffer.write(converted) + else: + print(converted) + else: + if isinstance(converted, str): + with open(outfile, "w") as file: + file.write(converted) + elif isinstance(converted, (bytes, bytearray)): + 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__ + ) + + except Exception: + out_tb = traceback.format_exc() + + # Send the error to wherever the user requested + if errfile == None: + print("Conversion codec error: " + str(out_tb), file=sys.stderr) + else: + with open(errfile, "w") as file: + file.write(str(out_tb)) + + sys.exit(200) + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 4f513ec..d41188f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,16 +1,22 @@ -import os, tempfile +import os, sys import pytest 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 = [sys.executable, "src/cq_cli/main.py"] out, err, exitcode = helpers.cli_call(command) - assert err.decode().split('\n')[0].startswith("usage") + assert ( + out.decode() + .split("\n")[0] + .startswith("Please specify at least the validate option") + ) + def test_codec_and_infile_arguments_file_nonexistent(): """ @@ -18,119 +24,236 @@ 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 = [ + sys.executable, + "src/cq_cli/main.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 = [ + sys.executable, + "src/cq_cli/main.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')[9].replace('\r', '') == "ISO-10303-21;" -def test_codec_infile_and_outfile_arguments(): +def test_codec_infile_and_outfile_arguments(tmp_path): """ Tests the CLI with the codec, infile and outfile set. """ test_file = helpers.get_test_file_location("cube.py") - # Get a temporary output file location - 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] + temp_file = tmp_path / "temp_test_4.step" + + command = [ + sys.executable, + "src/cq_cli/main.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(str(temp_file), "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") -def test_codec_infile_outfile_errfile_arguments(): + +def test_codec_infile_outfile_errfile_arguments(tmp_path): """ Tests the CLI with the codec, infile, outfile and errfile parameters set. The infile does not exist so that an error will be thrown. """ test_file = helpers.get_test_file_location("noexist.py") - # Get a temporary output file location - temp_dir = tempfile.gettempdir() - 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] + temp_file = tmp_path / "temp_test_5.step" + err_file = tmp_path / "temp_test_5_error.txt" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(temp_file), + "--errfile", + str(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(str(err_file), "r") as file: err_str = file.read() assert err_str == "Argument error: infile does not exist." -def test_parameter_file(): +def test_no_codec_parameter(tmp_path): + """ + Tests the CLI's ability to infer the codec from the outfile extension. + """ + test_file = helpers.get_test_file_location("cube.py") + + temp_file = tmp_path / "temp_test_12.step" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--infile", + test_file, + "--outfile", + str(temp_file), + ] + out, err, exitcode = helpers.cli_call(command) + + # Read the STEP output back from the outfile + with open(str(temp_file), "r") as file: + step_str = file.read() + + assert step_str.startswith("ISO-10303-21;") + + +def test_no_codec_parameter_multiple_infiles(tmp_path): + """ + Tests the CLI's ability to infer the codecs from multiple infile extensions. + """ + test_file = helpers.get_test_file_location("cube.py") + + temp_file_step = tmp_path / "temp_test_13.step" + temp_file_stl = tmp_path / "temp_test_13.stl" + temp_paths = f"{temp_file_step};{temp_file_stl}" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--infile", + test_file, + "--outfile", + temp_paths, + ] + out, err, exitcode = helpers.cli_call(command) + + # Read the STEP output back from the outfile to make sure it has the correct content + with open(str(temp_file_step), "r") as file: + step_str = file.read() + assert step_str.startswith("ISO-10303-21;") + + # Read the STL output back from the outfile to make sure it has the correct content + with open(str(temp_file_stl), "r") as file: + stl_str = file.read() + assert stl_str.startswith("solid") + + assert exitcode == 0 + + +def test_parameter_file(tmp_path): """ Tests the CLI's ability to load JSON parameters from a file. """ test_file = helpers.get_test_file_location("cube_params.py") params_file = helpers.get_test_file_location("cube_params.json") - # Get a temporary output file location - 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] + temp_file = tmp_path / "temp_test_6.step" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(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(str(temp_file), "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") -def test_parameter_json_string(): +def test_parameter_json_string(tmp_path): """ Tests the CLI's ability to load JSON parameters from the command line. """ test_file = helpers.get_test_file_location("cube_params.py") - # Get a temporary output file location - 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}"] + temp_file = tmp_path / "temp_test_7.step" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(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(str(temp_file), "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") -def test_parameter_delimited_string(): +def test_parameter_delimited_string(tmp_path): """ Tests the CLI's ability to load parameters from a colon and semi-colon delimited string. """ test_file = helpers.get_test_file_location("cube_params.py") - # Get a temporary output file location - 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;"] + temp_file = tmp_path / "temp_test_8.step" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(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(str(temp_file), "r") as file: step_str = file.read() assert step_str.startswith("ISO-10303-21;") @@ -142,109 +265,160 @@ 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 = [ + sys.executable, + "src/cq_cli/main.py", + "--getparams", + "true", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) # Grab the JSON output from cq-cli jsn = json.loads(out.decode()) - - # Check to make sure the first parameter was handled properly - assert jsn[0]["type"] == "number" - assert jsn[0]["name"] == "width" - assert jsn[0]["initial"] == 1 - - # Check to make sure the second parameter was handled properly - assert jsn[1]["type"] == "string" - assert jsn[1]["name"] == "tag_name" - assert jsn[1]["initial"] == "cube" - - # Check to make sure the third parameter was handled properly - assert jsn[2]["type"] == "boolean" - assert jsn[2]["name"] == "centered" - assert jsn[2]["initial"] == True - - -def test_parameter_file_input_output(): + params_by_name = helpers.params_list_to_dict(jsn) + + # Check to make sure the parameters were handled properly + assert params_by_name["width"] == {"name": "width", "type": "number", "initial": 1} + assert params_by_name["tag_name"] == { + "name": "tag_name", + "type": "string", + "initial": "cube", + } + assert params_by_name["centered"] == { + "name": "centered", + "type": "boolean", + "initial": True, + } + + +def test_parameter_file_input_output(tmp_path): """ Test the CLI's ability to extract parameters from a script, write them to a file, and then read them from the file again. """ test_file = helpers.get_test_file_location("cube_params.py") - # Get a temporary output file location - temp_dir = tempfile.gettempdir() - temp_file = os.path.join(temp_dir, "temp_test_9.json") + temp_file = tmp_path / "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 = [ + sys.executable, + "src/cq_cli/main.py", + "--getparams", + str(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 = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--params", + str(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(str(temp_file), "r") as file: json_str = file.read() - json_dict = json.loads(json_str) - json_dict[0]['initial'] = 10 - with open(temp_file, "w") as file: - file.writelines(json.dumps(json_dict)) + json_list = json.loads(json_str) + json_list[1]["initial"] = 10 + with open(str(temp_file), "w") as file: + file.writelines(json.dumps(json_list)) # Run the command with the new parameters - command3 = ["python", "cq-cli.py", "--codec", "stl", "--infile", test_file, '--params', temp_file] + command3 = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--params", + str(temp_file), + ] out3, err3, exitcode3 = helpers.cli_call(command3) # Make sure that the file output changed assert out2.decode() != out3.decode() -def test_params_stl_output(): +def test_params_stl_output(tmp_path): """ Test to specifically make sure that cq-cli will work with CadHub. """ test_file = helpers.get_test_file_location("cube_params.py") # Get a temporary output file locations - temp_dir = tempfile.gettempdir() - output_file_path = os.path.join(temp_dir, "output.stl") - default_output_file_path = os.path.join(temp_dir, "output_default.stl") - customizer_file_path = os.path.join(temp_dir, "customizer.json") - params_json_file_path = os.path.join(temp_dir, "params.json") + output_file_path = tmp_path / "output.stl" + default_output_file_path = tmp_path / "output_default.stl" + customizer_file_path = tmp_path / "customizer.json" + params_json_file_path = tmp_path / "params.json" # Fake out the params.json file that would be coming from the user's interaction with CadHub params_json = {} params_json["width"] = 10 params_json["tag_name"] = "cube_default" params_json["centered"] = False - with open(params_json_file_path, "w") as file: + with open(str(params_json_file_path), "w") as file: 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 = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + str(output_file_path), + "--params", + str(params_json_file_path), + "--getparams", + str(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(str(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 + json_list = json.loads(json_str) + params = helpers.params_list_to_dict(json_list) + assert params["width"]["initial"] == 1 + assert params["tag_name"]["initial"] == "cube" + assert params["centered"]["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 = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + str(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(str(output_file_path), "r") as file3: stl_output_with_params = file3.read() - with open(default_output_file_path, 'r') as file4: + with open(str(default_output_file_path), "r") as file4: default_stl = file4.read() assert stl_output_with_params != default_stl @@ -256,7 +430,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 = [sys.executable, "src/cq_cli/main.py"] out, err, exitcode = helpers.cli_call(command) # Make sure that we got exit code 2 @@ -266,8 +440,667 @@ 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 = [ + sys.executable, + "src/cq_cli/main.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 assert exitcode == 100 + + +def test_expression_argument(tmp_path): + """ + Tests the CLI with the the expression argument. + """ + test_file = helpers.get_test_file_location("no_toplevel_objects.py") + + temp_file = tmp_path / "temp_test_10.step" + + # Run cq-cli with --expression "cube()" + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(temp_file), + "--expression", + "cube()", + ] + out, err, exitcode = helpers.cli_call(command) + + # Read the STEP output back from the outfile + with open(str(temp_file), "r") as file: + step_str = file.read() + + assert step_str.startswith("ISO-10303-21;") + + # Run cq-cli on the same model file, but don't specify an --expression. This + # should fail because the file contains no top-level show_object() calls. + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(temp_file), + ] + out, err, exitcode = helpers.cli_call(command) + + # cq-cli invocation should fail + assert exitcode == 200 + + +def test_multiple_outfiles(tmp_path): + """ + Tests the CLI with multiple output files specified. + """ + test_file = helpers.get_test_file_location("cube.py") + + temp_file_step = tmp_path / "temp_test_11.step" + temp_file_stl = tmp_path / "temp_test_11.stl" + temp_paths = f"{temp_file_step};{temp_file_stl}" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step;stl", + "--infile", + test_file, + "--outfile", + temp_paths, + ] + out, err, exitcode = helpers.cli_call(command) + assert exitcode == 0 + + +def test_stl_stdout_is_binary_safe(): + """ + Tests that STL output written to stdout is valid binary/text STL content + (not a Python bytes repr like b'solid ...'). + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + # Output must start with the STL header, not a Python bytes repr + assert out[:5] == b"solid" + + +def test_stl_output_opts_none_does_not_crash(tmp_path): + """ + Tests that passing no --outputopts to the STL codec does not crash + (guards against None passed to output_opts). + """ + test_file = helpers.get_test_file_location("cube.py") + out_path = tmp_path / "out.stl" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + str(out_path), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + assert err.decode() == "" + + +def test_invalid_codec_exit_code(): + """ + Tests that specifying an unknown codec exits with code 3. + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "nonexistentcodec", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 3 + + +def test_validate_valid_script(): + """ + Tests that --validate true returns 'validation_success' for a valid script. + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--validate", + "true", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + assert "validation_success" in out.decode() + + +def test_validate_invalid_script(): + """ + Tests that --validate true exits with code 100 for a broken script. + """ + test_file = helpers.get_test_file_location("impossible_cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--validate", + "true", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 100 + + +def test_outputopts_quoted_string(tmp_path): + """ + Tests that quoted string output options are stored without surrounding quotes. + This guards against the bug where 'value' was stored as "'value'" instead of "value". + Uses SVG codec which passes outputopts through. + """ + test_file = helpers.get_test_file_location("cube.py") + out_path = tmp_path / "out.svg" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "svg", + "--infile", + test_file, + "--outfile", + str(out_path), + "--outputopts", + "strokeColor:'#FF0000';", + ] + out, err, exitcode = helpers.cli_call(command) + + # Should not crash parsing the quoted string option + assert exitcode == 0 + + +def test_params_single_char_does_not_crash(): + """ + Tests that a single-character --params value does not crash with an IndexError + on the Windows path detection code (args.params[1]). + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--params", + "x", + ] + out, err, exitcode = helpers.cli_call(command) + + # Should not crash with IndexError - exit 0 or 100 depending on script, not 1 + assert exitcode != 1 + + +def test_build_error_written_to_errfile(tmp_path): + """ + Tests that a build error (exception object) is correctly written as a string + to the errfile, not crashing with TypeError. + """ + test_file = helpers.get_test_file_location("impossible_cube.py") + err_file = tmp_path / "build_error.txt" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--errfile", + str(err_file), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 100 + with open(str(err_file), "r") as f: + err_content = f.read() + # Must be a non-empty string, not a crash + assert len(err_content) > 0 + + +def test_file_variable_is_set(tmp_path): + """ + Tests that cq-cli sets the __file__ variable for the model script. + """ + test_file = helpers.get_test_file_location("file_var.py") + + out_path = tmp_path / "temp_test_file_variable.stl" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + str(out_path), + ] + out, err, exitcode = helpers.cli_call(command) + assert exitcode == 0 + assert "__file__=" in out.decode() + + +def test_stdin_input(): + """ + Tests that a CadQuery script piped via stdin produces valid STEP output. + """ + import subprocess + + test_file = helpers.get_test_file_location("cube.py") + with open(test_file, "r") as f: + script = f.read() + + proc = subprocess.Popen( + [sys.executable, "src/cq_cli/main.py", "--codec", "step", "--outfile", "-"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # Pass script via stdin; use --outfile - equivalent: no --outfile means stdout + proc2 = subprocess.Popen( + [sys.executable, "src/cq_cli/main.py", "--codec", "step"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out, err = proc2.communicate(input=script.encode()) + + assert "ISO-10303-21;" in out.decode() + + +def test_stdin_with_outfile(tmp_path): + """ + Tests that a CadQuery script piped via stdin with --outfile writes correct output. + """ + import subprocess + + test_file = helpers.get_test_file_location("cube.py") + with open(test_file, "r") as f: + script = f.read() + + out_path = tmp_path / "stdin_out.step" + + proc = subprocess.Popen( + [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--outfile", + str(out_path), + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out, err = proc.communicate(input=script.encode()) + + assert proc.returncode == 0 + with open(str(out_path), "r") as f: + content = f.read() + assert content.startswith("ISO-10303-21;") + + +def test_parameter_delimited_string_multiple_params(tmp_path): + """ + Tests that multiple key:value pairs in a single --params string all take effect. + Passes width=2 and centered=False together and confirms output differs from defaults. + """ + test_file = helpers.get_test_file_location("cube_params.py") + out_default = tmp_path / "default.step" + out_custom = tmp_path / "custom.step" + + command_default = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(out_default), + ] + helpers.cli_call(command_default) + + command_custom = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(out_custom), + "--params", + "width:2;", + ] + out, err, exitcode = helpers.cli_call(command_custom) + + assert exitcode == 0 + with open(str(out_default), "r") as f: + default_content = f.read() + with open(str(out_custom), "r") as f: + custom_content = f.read() + assert default_content != custom_content + + +def test_parameter_json_string_multiple_params(tmp_path): + """ + Tests that a JSON --params string with multiple keys all apply correctly. + """ + test_file = helpers.get_test_file_location("cube_params.py") + out_path = tmp_path / "multi_json.step" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--outfile", + str(out_path), + "--params", + '{"width": 5, "centered": false}', + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + with open(str(out_path), "r") as f: + content = f.read() + assert content.startswith("ISO-10303-21;") + + +def test_getparams_with_no_params_script(): + """ + Tests that --getparams on a script with no user-defined parameters returns only + the injected __file__ entry (a side-effect of __file__ prepending), with no other + named parameters. + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--getparams", + "true", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + result = json.loads(out.decode()) + user_params = [p for p in result if p["name"] != "__file__"] + assert user_params == [] + + +def test_getparams_writes_file_and_returns_expected_keys(tmp_path): + """ + Tests that --getparams writes a JSON file containing the expected parameter names. + """ + test_file = helpers.get_test_file_location("cube_params.py") + params_out = tmp_path / "params.json" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--getparams", + str(params_out), + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + assert params_out.exists() and params_out.stat().st_size > 0 + params = json.loads(params_out.read_text()) + names = [p["name"] for p in params] + assert "width" in names + assert "tag_name" in names + assert "centered" in names + + +def test_validate_with_outfile(tmp_path): + """ + Tests that --validate true with --outfile writes 'validation_success' to the file. + """ + test_file = helpers.get_test_file_location("cube.py") + out_path = tmp_path / "validation.txt" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--validate", + "true", + "--infile", + test_file, + "--outfile", + str(out_path), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + assert out_path.read_text() == "validation_success" + + +def test_syntax_error_exits_100(): + """ + Tests that a script with a Python syntax error exits with code 100. + """ + test_file = helpers.get_test_file_location("syntax_error.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 100 + + +def test_syntax_error_written_to_errfile(tmp_path): + """ + Tests that a script syntax error writes a traceback to errfile. + """ + test_file = helpers.get_test_file_location("syntax_error.py") + err_file = tmp_path / "syntax_err.txt" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--errfile", + str(err_file), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 100 + content = err_file.read_text() + assert len(content) > 0 + + +def test_codec_error_written_to_errfile(tmp_path): + """ + Tests that a codec-level failure (exit 200) writes traceback to errfile. + Uses the expression argument to trigger a no-results error. + """ + test_file = helpers.get_test_file_location("no_toplevel_objects.py") + err_file = tmp_path / "codec_err.txt" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "step", + "--infile", + test_file, + "--errfile", + str(err_file), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 200 + content = err_file.read_text() + assert len(content) > 0 + + +def test_auto_codec_detection_stl(tmp_path): + """ + Tests that the STL codec is inferred from a .stl output file extension. + """ + test_file = helpers.get_test_file_location("cube.py") + out_path = tmp_path / "auto.stl" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--infile", + test_file, + "--outfile", + str(out_path), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + content = out_path.read_bytes() + assert content[:5] == b"solid" + + +def test_auto_codec_detection_svg(tmp_path): + """ + Tests that the SVG codec is inferred from a .svg output file extension. + """ + test_file = helpers.get_test_file_location("cube.py") + out_path = tmp_path / "auto.svg" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--infile", + test_file, + "--outfile", + str(out_path), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + content = out_path.read_text() + assert ' out_low.stat().st_size + + +def test_stl_codec_assembly_to_file(tmp_path): + """ + Tests that an assembly exported to an STL file produces valid content. + """ + test_file = helpers.get_test_file_location("cube_assy.py") + out_path = tmp_path / "assy.stl" + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--outfile", + str(out_path), + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + content = out_path.read_bytes() + assert content[:5] == b"solid" + + +def test_stl_codec_binary(): + """ + Tests exporting to binary stl format. + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "stl", + "--infile", + test_file, + "--outputopts", + "binary:True", + ] + out, err, exitcode = helpers.cli_call(command) + + assert out[:5] != b"solid" diff --git a/tests/test_svg_codec.py b/tests/test_svg_codec.py index c8cbc16..e06485d 100644 --- a/tests/test_svg_codec.py +++ b/tests/test_svg_codec.py @@ -1,12 +1,115 @@ +import sys 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 = [ + sys.executable, + "src/cq_cli/main.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", "") + == '' + ) + + +def test_svg_codec_with_assembly(): + """ + Test of the SVG codec plugin with a CadQuery assembly. + """ + test_file = helpers.get_test_file_location("cube_assy.py") + + command = [ + sys.executable, + "src/cq_cli/main.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", "") + == '' + ) + + +def test_svg_codec_default_opts(): + """ + Tests that the SVG codec works with no --outputopts (no crash on None opts). + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "svg", + "--infile", + test_file, + ] + out, err, exitcode = helpers.cli_call(command) + + assert exitcode == 0 + assert ' element, + confirming actual geometry was rendered (not just an empty SVG wrapper). + """ + test_file = helpers.get_test_file_location("cube.py") + + command = [ + sys.executable, + "src/cq_cli/main.py", + "--codec", + "svg", + "--infile", + test_file, + ] out, err, exitcode = helpers.cli_call(command) - assert out.decode().split('\n')[0].replace('\r', '') == "" \ No newline at end of file + assert exitcode == 0 + assert "