diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 28a6acb..1b72983 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,19 +9,19 @@ on: jobs: run-black-lint: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10"] steps: - - uses: actions/checkout@v2 + - 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: | - pip install --upgrade pip - pip install -e . - pip install -e .[dev] + 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 . + 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 58ac2f6..59e64e5 100644 --- a/.github/workflows/pyinstaller-builds-actions.yml +++ b/.github/workflows/pyinstaller-builds-actions.yml @@ -12,13 +12,13 @@ jobs: build-linux: 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" 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} @@ -32,19 +32,19 @@ jobs: run: | 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} @@ -58,19 +58,19 @@ 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: miniforge-version: latest # auto-update-conda: true - python-version: 3.8 + python-version: 3.11 activate-environment: test - name: Install CadQuery and pyinstaller shell: pwsh @@ -86,7 +86,7 @@ jobs: 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 index 7a06dcb..4375c5f 100644 --- a/.github/workflows/pyinstaller.yml +++ b/.github/workflows/pyinstaller.yml @@ -10,10 +10,10 @@ jobs: build-linux: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | pip install --upgrade pip @@ -24,17 +24,17 @@ jobs: - name: Run PyInstaller build run: | pyinstaller 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: actions/setup-python@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | pip install --upgrade pip @@ -45,17 +45,17 @@ jobs: - name: Run PyInstaller build run: | pyinstaller 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: actions/setup-python@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | pip install --upgrade pip @@ -69,7 +69,7 @@ jobs: # CPATH=$pythonLocation/include/python${{ matrix.python-version }}m # echo "CPATH=$CPATH" >> $GITHUB_ENV pyinstaller 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/test_freecad.yml b/.github/workflows/test_freecad.yml index 6f97e4a..e2647b5 100644 --- a/.github/workflows/test_freecad.yml +++ b/.github/workflows/test_freecad.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout project - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install and Test run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8782ead..cfd0da5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,19 +11,23 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10"] + 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-20.04' - - uses: actions/checkout@v2 + 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: | - pip3 install --upgrade pip - pip3 install -e . - pip3 install -e .[dev] + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install -e .[dev] - name: Run tests run: | - python3 -m pytest -v --ignore=tests/test_freecad.py + python -m pytest -v --ignore=tests/test_freecad.py diff --git a/.gitignore b/.gitignore index ac5fe8d..e0e64f2 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,4 @@ dmypy.json # FreeCAD related files updated_part* +.DS_Store diff --git a/README.md b/README.md index f05daa3..1aabdde 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,82 @@ # cq-cli -[![tests](https://github.com/CadQuery/cq-cli/actions/workflows/tests.yml/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 beta. +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. -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. +**Requires Python 3.11 or later.** Linux, macOS, and Windows are supported, though some features may behave differently on Windows. -Linux, MacOS and Windows are supported, but some features may be used differently or may not work the same in Windows. +## Installation -## Getting Help +### uv (preferred) ⭐️ -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](https://docs.astral.sh/uv/) is the recommended way to install and run cq-cli. It handles Python version management and virtual environments automatically. -## Installation (pip) +``` +uv venv --python 3.11 +source .venv/bin/activate # Windows: .venv\Scripts\activate +uv sync +``` -***Note:*** It probably goes without saying, but it is best to use a Python virtual environment when installing a Python package like cq-cli. +Once complete, run cq-cli with: +``` +cq-cli --help +``` -These instructions cover installing cq-cli using pip. If you want a stand-alone installation that does not require any of the Python infrastructure, read the Stand-Alone section below. +### pip -cq-cli is not available on PyPI, so it must be installed using pip and git. git must be installed for this process to work. +It is strongly recommended to use a Python virtual environment when installing via pip. + +cq-cli is not available on PyPI, so it must be installed from source using pip and git. git must be installed for this to work. ``` pip install git+https://github.com/CadQuery/cq-cli.git ``` -Once the installation is complete, there will be two different ways to run the cq-cli command line interface. +Once the installation is complete, the CLI can be invoked as: ``` cq-cli --help ``` -or +or: ``` python -m cq_cli.main --help ``` -## Installation (Stand-Alone) - -**Please note:** This method is not recommended now that cq-cli can be installed via pip, but it is still an option if it is not possible to use a Python virtual environment. - -Download a binary distribution that is appropriate for your operating system from the [latest PyInstaller workflow run with a green checkmark](https://github.com/CadQuery/cq-cli/actions/workflows/pyinstaller.yml), 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). - -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. - - -## Drawbacks of Stand-Alone Installation - -* 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`. - ## 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 @@ -98,89 +98,109 @@ cq-cli --codec step --infile /input/path/script.py --outfile /output/path/newfil ``` 5. Convert a CadQuery script and write any errors to a separate file. ``` -cq-cli --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 stdin and stdout streams. This example counts the lines in the resulting STEP output. +``` +cat /input/path/script.py | cq-cli --codec step | wc -l +``` +7. Let cq-cli infer the codec from the output file extension. ``` -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. +cq-cli --infile /input/path/script.py --outfile /output/path/newfile.stl ``` -cat /input/path/script.py | cq-cli.py --codec step | wc -l +8. Convert to multiple output formats in a single invocation. ``` -7. Convert a CadQuery script to SVG, passing in output options to influence the resulting image. +cq-cli --infile /input/path/script.py --outfile /output/path/model.step;/output/path/model.stl +``` +9. Convert a CadQuery script to SVG, passing output options to influence the resulting image. ``` 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;" ``` -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). +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 --codec stl --infile /input/path/script.py --outfile /output/path/script.stl --outputopts "linearDeflection:0.3;angularDeflection:0.3" ``` -9. Extract parameter information from the input script. The outfile argument can also be left off to output the parameter JSON to stdout. +11. Extract parameter information from a script. Omit the file path to print JSON to stdout. ``` cq-cli --getparams /output/path/params.json --infile /input/path/script.py ``` -10. Pass JSON parameter information from a file to be used in the script. +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 ``` -11. Pass JSON parameter data as a string on the command line. +13. Pass JSON parameter data as a string on the command line. ``` cq-cli --codec stl --infile /input/path/script.py --params "{\"width\":10}" ``` -12. String parameters can be defined using single quotes (`'`) or escaped double quotes (`\"`). +14. Pass parameters as a colon/semicolon delimited string. ``` -cq-cli --codec stl --outfile test.stl --infile /input/path/script.py --outputopts "width:2;tag_name:'test';centered:True" +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 stl --outfile test.stl --infile /input/path/script.py --outputopts "width:2;tag_name:\"test\";centered:True" +cq-cli --codec step --infile /input/path/script.py --outfile /output/path/part.step --expression "my_part(x=5)" ``` ## 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 -1. Create a Python virtual environment and activate it. Attept to avoid the bleeding-edge version of Python as there may be problems. -2. Clone this repository: `git clone https://github.com/CadQuery/cq-cli.git` -3. cd into the repository directory: `cd cq-cli` -4. Do a local editable installation via pip: `pip install -e .` +The recommended way to set up a development environment is with [uv](https://docs.astral.sh/uv/). -### Adding a Codec +``` +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 +``` + +Alternatively, using pip: +``` +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] +``` + +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. +This repository has five GitHub Actions workflows in \`.github/workflows\`: -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. +**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 onefile -``` -pyinstaller cq-cli_pyinstaller.spec onefile -``` - -#### 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. \ No newline at end of file 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/pyproject.toml b/pyproject.toml index a4a499c..3324cec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ ] description = "Command Line Interface for executing CadQuery scripts and converting their output to another format." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", @@ -27,8 +27,8 @@ cq-cli = "cq_cli.main:main" [project.optional-dependencies] dev = [ "pytest", - "black==19.10b0", - "click==8.0.4" + "black==26.3.1", + "click==8.3.1" ] [project.urls] diff --git a/src/cq_cli/cqcodecs/cq_codec_stl.py b/src/cq_cli/cqcodecs/cq_codec_stl.py index 3f67e64..3c38525 100644 --- a/src/cq_cli/cqcodecs/cq_codec_stl.py +++ b/src/cq_cli/cqcodecs/cq_codec_stl.py @@ -13,9 +13,9 @@ 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"] # The exporters will add extra output that we do not want, so suppress it @@ -33,7 +33,7 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): result.exportStl(temp_file, linearDeflection, angularDeflection, True) # 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 index bc7d6be..7a9aff4 100644 --- a/src/cq_cli/cqcodecs/cq_codec_svg.py +++ b/src/cq_cli/cqcodecs/cq_codec_svg.py @@ -19,7 +19,10 @@ def convert(build_result, output_file=None, error_file=None, output_opts=None): # Put the STEP output into the temp file exporters.export( - result, temp_file, exporters.ExportTypes.SVG, opt=output_opts, + result, + temp_file, + exporters.ExportTypes.SVG, + opt=output_opts, ) # Read the STEP output back in diff --git a/src/cq_cli/main.py b/src/cq_cli/main.py index 4eb32c6..8a09c69 100755 --- a/src/cq_cli/main.py +++ b/src/cq_cli/main.py @@ -426,6 +426,13 @@ def main(): 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: @@ -460,7 +467,7 @@ def main(): or args.params.startswith(".") or args.params.startswith("..") or args.params.startswith("~") - or args.params[1] == ":" + 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) @@ -506,7 +513,7 @@ def main(): elif "." in op1: op = float(opt_parts[1]) elif '"' in op1 or "'" in op1: - op = str(opt_parts[1]) + op = str(opt_parts[1]).strip("\"'") else: op = int(opt_parts[1]) @@ -528,7 +535,7 @@ def main(): print("build_and_parse error: " + str(err), file=sys.stderr) else: with open(errfile, "w") as file: - file.write(err) + file.write(str(err)) sys.exit(100) # @@ -555,7 +562,10 @@ def main(): if converted != None: # Write the converted output to the appropriate place based on the command line arguments if outfile == None: - print(converted) + if isinstance(converted, (bytes, bytearray)): + sys.stdout.buffer.write(converted) + else: + print(converted) else: if isinstance(converted, str): with open(outfile, "w") as file: diff --git a/tests/test_cli.py b/tests/test_cli.py index 047a0f3..d41188f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -import os, tempfile +import os, sys import pytest import tests.test_helpers as helpers import json @@ -8,7 +8,7 @@ def test_no_cli_arguments(): """ Runs the CLI with no arguments, which you should not do unless you want the usage message. """ - command = ["python", "src/cq_cli/main.py"] + command = [sys.executable, "src/cq_cli/main.py"] out, err, exitcode = helpers.cli_call(command) assert ( @@ -25,7 +25,7 @@ def test_codec_and_infile_arguments_file_nonexistent(): test_file = helpers.get_test_file_location("noexist.py") command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", @@ -44,7 +44,7 @@ def test_codec_and_infile_arguments(): test_file = helpers.get_test_file_location("cube.py") command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", @@ -55,18 +55,16 @@ def test_codec_and_infile_arguments(): assert "ISO-10303-21;" in out.decode() -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") + temp_file = tmp_path / "temp_test_4.step" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", @@ -78,86 +76,80 @@ def test_codec_infile_and_outfile_arguments(): 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") + temp_file = tmp_path / "temp_test_5.step" + err_file = tmp_path / "temp_test_5_error.txt" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", "--infile", test_file, "--outfile", - temp_file, + str(temp_file), "--errfile", - err_file, + 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_no_codec_parameter(): +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") - # Get a temporary output file location - temp_dir = tempfile.gettempdir() - temp_file = os.path.join(temp_dir, "temp_test_12.step") + temp_file = tmp_path / "temp_test_12.step" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--infile", test_file, "--outfile", - temp_file, + str(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_no_codec_parameter_multiple_infiles(): +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") - # Get a temporary output file location - temp_dir = tempfile.gettempdir() - temp_file_step = os.path.join(temp_dir, "temp_test_13.step") - temp_file_stl = os.path.join(temp_dir, "temp_test_13.stl") - temp_paths = temp_file_step + ";" + temp_file_stl + 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 = [ - "python", + sys.executable, "src/cq_cli/main.py", "--infile", test_file, @@ -167,107 +159,101 @@ def test_no_codec_parameter_multiple_infiles(): 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(temp_file_step, "r") as file: + 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(temp_file_stl, "r") as file: + 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(): +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") + temp_file = tmp_path / "temp_test_6.step" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", "--infile", test_file, "--outfile", - temp_file, + 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") + temp_file = tmp_path / "temp_test_7.step" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", "--infile", test_file, "--outfile", - temp_file, + 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") + temp_file = tmp_path / "temp_test_8.step" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", "--infile", test_file, "--outfile", - temp_file, + 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;") @@ -280,7 +266,7 @@ def test_parameter_analysis(): test_file = helpers.get_test_file_location("cube_params.py") command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--getparams", "true", @@ -307,23 +293,21 @@ def test_parameter_analysis(): } -def test_parameter_file_input_output(): +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", + sys.executable, "src/cq_cli/main.py", "--getparams", - temp_file, + str(temp_file), "--infile", test_file, ] @@ -331,37 +315,37 @@ def test_parameter_file_input_output(): # Run the script with baseline parameters command2 = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "stl", "--infile", test_file, "--params", - temp_file, + 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_list = json.loads(json_str) json_list[1]["initial"] = 10 - with open(temp_file, "w") as file: + with open(str(temp_file), "w") as file: file.writelines(json.dumps(json_list)) # Run the command with the new parameters command3 = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "stl", "--infile", test_file, "--params", - temp_file, + str(temp_file), ] out3, err3, exitcode3 = helpers.cli_call(command3) @@ -369,41 +353,40 @@ def test_parameter_file_input_output(): 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", + sys.executable, "src/cq_cli/main.py", "--codec", "stl", "--infile", test_file, "--outfile", - output_file_path, + str(output_file_path), "--params", - params_json_file_path, + str(params_json_file_path), "--getparams", - customizer_file_path, + str(customizer_file_path), ] out, err, exitcode = helpers.cli_call(command) @@ -411,7 +394,7 @@ def test_params_stl_output(): 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_list = json.loads(json_str) params = helpers.params_list_to_dict(json_list) @@ -421,21 +404,21 @@ def test_params_stl_output(): # Write an STL using the default parameters so that we can compare it to what was generated with customized parameters command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "stl", "--infile", test_file, "--outfile", - default_output_file_path, + 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 @@ -447,7 +430,7 @@ def test_exit_codes(): """ # Test to make sure we get the correct exit code when no parameters are specified - command = ["python", "src/cq_cli/main.py"] + command = [sys.executable, "src/cq_cli/main.py"] out, err, exitcode = helpers.cli_call(command) # Make sure that we got exit code 2 @@ -458,7 +441,7 @@ def test_exit_codes(): # Execute the script with the current parameters and save the new parameter metadata to the customizer file command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", @@ -471,33 +454,31 @@ def test_exit_codes(): assert exitcode == 100 -def test_expression_argument(): +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") - # Get a temporary output file location - temp_dir = tempfile.gettempdir() - temp_file = os.path.join(temp_dir, "temp_test_10.step") + temp_file = tmp_path / "temp_test_10.step" # Run cq-cli with --expression "cube()" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", "--infile", test_file, "--outfile", - temp_file, + str(temp_file), "--expression", "cube()", ] 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;") @@ -505,14 +486,14 @@ def test_expression_argument(): # 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 = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step", "--infile", test_file, "--outfile", - temp_file, + str(temp_file), ] out, err, exitcode = helpers.cli_call(command) @@ -520,20 +501,18 @@ def test_expression_argument(): assert exitcode == 200 -def test_multiple_outfiles(): +def test_multiple_outfiles(tmp_path): """ Tests the CLI with multiple output files specified. """ test_file = helpers.get_test_file_location("cube.py") - # Get a temporary output file location - temp_dir = tempfile.gettempdir() - temp_file_step = os.path.join(temp_dir, "temp_test_11.step") - temp_file_stl = os.path.join(temp_dir, "temp_test_11.stl") - temp_paths = temp_file_step + ";" + temp_file_stl + 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 = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "step;stl", @@ -546,25 +525,582 @@ def test_multiple_outfiles(): assert exitcode == 0 -def test_file_variable_is_set(): +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") - temp_dir = tempfile.gettempdir() - out_path = os.path.join(temp_dir, "temp_test_file_variable.stl") + out_path = tmp_path / "temp_test_file_variable.stl" command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "stl", "--infile", test_file, "--outfile", - out_path, + 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" diff --git a/tests/test_svg_codec.py b/tests/test_svg_codec.py index 1e3561b..e06485d 100644 --- a/tests/test_svg_codec.py +++ b/tests/test_svg_codec.py @@ -1,3 +1,4 @@ +import sys import tests.test_helpers as helpers @@ -8,7 +9,7 @@ def test_svg_codec(): test_file = helpers.get_test_file_location("cube.py") command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "svg", @@ -32,7 +33,7 @@ def test_svg_codec_with_assembly(): test_file = helpers.get_test_file_location("cube_assy.py") command = [ - "python", + sys.executable, "src/cq_cli/main.py", "--codec", "svg", @@ -47,3 +48,68 @@ def test_svg_codec_with_assembly(): 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 exitcode == 0 + assert "