diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..14b7f80 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,39 @@ +# adapted from https://github.com/anze3db/words-tui/blob/main/.github/workflows/publish.yml +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + environment: release + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + contents: write # github release upload + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + - name: Build package + run: poetry build + - name: Upload release artifacts + run: gh release upload ${{ github.event.release.tag_name }} dist/*.{tar.gz,whl} + env: + GH_TOKEN: ${{ github.token }} + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests-run.yml b/.github/workflows/tests-run.yml new file mode 100644 index 0000000..fbc243b --- /dev/null +++ b/.github/workflows/tests-run.yml @@ -0,0 +1,78 @@ +name: Run tests + +on: + pull_request: + types: [ opened, synchronize, reopened ] + push: + branches: [ main ] + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +jobs: + run-tests: + runs-on: ${{matrix.os}} + env: + BB_BLENDER_TEST_HOME: "~/blender" + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + version: ['3.11'] + blender: ['4.2.0'] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.version }} + + - name: Install poetry + uses: abatilo/actions-poetry@v2 + + - name: Setup a local virtual environment + run: | + poetry config virtualenvs.create true --local + poetry config virtualenvs.in-project true --local + - uses: actions/cache@v3 + name: Define a cache for the virtual environment based on the dependencies lock file + with: + path: ./.venv + key: venv-${{ runner.os }}-${{ matrix.version }}-${{ hashFiles('poetry.lock') }} + + - name: Install the project dependencies + run: poetry install + + - name: Run tests + run: poetry run pytest -v + + - name: Restore Blender from cache + id: cache-blender + uses: actions/cache/restore@v3 + with: + path: ~/blender + key: ${{ runner.os }}-blender-${{ matrix.blender }} + + - name: Install Blender + if: steps.cache-blender.outputs.cache-hit != 'true' + run: | + poetry run python scripts/blender_install.py ${{ matrix.blender }} + + - name: "Save Blender in cache" + if: steps.cache-blender.outputs.cache-hit != 'true' + uses: actions/cache/save@v3 + with: + path: ~/blender + key: ${{ steps.cache-blender.outputs.cache-primary-key }} + + - name: Install EGL mesa + if: "startsWith (matrix.os, 'ubuntu')" + run: | + sudo apt-get update -y -qq + sudo apt-get install -y -qq libegl1-mesa libegl1-mesa-dev + + - name: Run integration tests + run: | + poetry build + poetry run python scripts/bb_package_install.py + poetry run pytest -m integration -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72c9acd --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*$py.class + +.bb-tmp +dist/ + +\#*# +.#* +*~ + +.nrepl-port diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..18bc3ee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## Unreleased + +## 0.1.0 + +- Added support for evaluating Basilisp code from Blender's Python console, file, and Text Editor. +- Implemented functionality to start the nREPL server from Blender. + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e48e096 --- /dev/null +++ b/LICENSE @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/README.md b/README.md new file mode 100644 index 0000000..20794c7 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +[![PyPI](https://img.shields.io/pypi/v/basilisp-blender.svg?style=flat-square)](https://pypi.org/project/basilisp-blender/) [![CI](https://github.com/ikappaki/basilisp-blender/actions/workflows/tests-run.yml/badge.svg)](https://github.com/ikappaki/basilisp-blender/actions/workflows/tests-run.yml) + +# Basilisp Blender Integration + +[Basilisp](https://github.com/basilisp-lang/basilisp) is a Python-based Lisp implementation that offers broad compatibility with Clojure. For more details, refer to the [documentation](https://basilisp.readthedocs.io/en/latest/index.html). + +## Overview +`basilisp-blender` is a Python library designed to facilitate the execution of Basilisp Clojure code within [Blender](https://www.blender.org/) and manage an nREPL server for interactive programming. +This library provides functions to evaluate Basilisp code from Blender's Python console, file or Text Editor and to start an nREPL server, allowing seamless integration and communication with Basilisp. + +## Installation +To install `basilisp-blender`, use `pip` from Blender's Python console: + +```python +import pip +pip.main(['install', 'basilisp-blender']) +``` + +## Usage +### Evaluating Basilisp Code + +#### From a Code String +To evaluate a Basilisp code string: + +```python +from basilisp_blender.eval import eval_str + +eval_str("(+ 1 2)") +# => 3 +``` + +#### From a File +To evaluate Basilisp code from a file: + +```python +from basilisp_blender.eval import eval_file + +eval_file("path/to/your/code.lpy") +``` + +#### From Blender’s Text Editor +To evaluate Basilisp code contained in a Blender text editor block: + +```python +from basilisp_blender.eval import eval_editor + +# Replace `text_block` with your Blender text block name +eval_editor("") +``` + +#### Starting an nREPL Server +To start an nREPL server within Blender: + +```python +from basilisp_blender.nrepl import server_start + +shutdown_fn = server_start(host="127.0.0.1", port=8889) +``` + +The `host` and `port` arguments are optional. +If not provided, the server will bind to a random local port. +It will also creates an `.nrepl-port` file in the current working directory containing the port number it bound to. + +The return value is a function that you can call without arguments to shut down the server. +Note that all nREPL client sessions must be closed before this function can succesfullyl shutdown the server. + +For a more convenient setup, you can specify to output `.nrepl-port` file to your Basilisp's project's root directory. +This allows some Clojure editor extensions (such as [CIDER](https://docs.cider.mx/cider/index.html) or [Calva](https://calva.io/)) to automatically detect the port when `connect`'ing to the server: + +```python +from basilisp_blender.nrepl import server_start + +shutdown_fn = server_start(nrepl_port_filepath="/.nrepl-port") +``` + +Replace `` with the path to your project's root directory. + +# Examples + +Also see the [examples](examples/) directory of this repository. + +Here is an example of Basilisp code to create a torus pattern using the bpy Blender Python library: + +```clojure +(ns torus-pattern + "Creates a torus pattern with randomly colored materials." + (:import bpy + math)) + +(def object (.. bpy/ops -object)) +(def materials (.. bpy/data -materials)) +(def mesh (.. bpy/ops -mesh)) + + +(defn clear-mesh-objects [] + (.select-all object ** :action "DESELECT") + (.select-by-type object ** :type "MESH") + (.delete object)) + +(clear-mesh-objects) + +(defn create-random-material [] + (let [mat (.new materials ** :name "RandomMaterial") + _ (set! (.-use-nodes mat) true) + bsdf (aget (.. mat -node-tree -nodes) "Principled BSDF")] + + (set! (-> bsdf .-inputs (aget "Base Color") .-default-value) + [(rand) (rand) (rand) 1]) + mat)) + +(defn create-torus [radius tube-radius location segments] + (.primitive-torus-add mesh ** + :major-radius radius + :minor-radius tube-radius + :location location + :major-segments segments + :minor-segments segments) + (let [obj (.. bpy/context -object) + material (create-random-material)] + (-> obj .-data .-materials (.append material)))) + +#_(create-torus 5, 5, [0 0 0] 48) + +(defn create-pattern [{:keys [layers-num radius tube-radius] + :or {layers-num 2 + radius 2 + tube-radius 0.2}}] + (let [angle-step (/ math/pi 4)] + (dotimes [i layers-num] + (let [layer-radius (* radius (inc i)) + objects-num (* 12 (inc i))] + (dotimes [j objects-num] + (let [angle (* j angle-step) + x (* layer-radius (math/cos angle)) + y (* layer-radius (math/sin angle)) + z (* i 0.5)] + (create-torus (/ radius 2) tube-radius [x y z] 48))))))) + +(create-pattern {:layers-num 5}) +``` + +![torus pattern example img](examples/torus-pattern.png) + +# Troubleshooting + +If you encounter unexplained errors, enable `DEBUG` logging and save the output to a file for inspection. For example: + +```python +import logging +from basilisp_blender import log_level_set + +log_level_set(logging.DEBUG, filepath="bblender.log") +``` + +Blender scripting [is not hread safe](https://docs.blender.org/api/current/info_gotcha.html#strange-errors-when-using-the-threading-module). +As a result, the nREPL server cannot be started into a background thread and still expect calling `bpy` functions to work without corrupting its state. + +To work around this limitation, the nREPL server is started in a thread, but client requests are differed into a queue that will be executed later by a `bpy` custom timer function. +The function is run in the main Blender loop at intervals of 0.1 seconds, avoiding parallel operations that could affect Blender's state. + +If necessary, you can adjust this interval to better suit your needs by passing the `interval_sec` argument to the `server_start` function: + +```python +from basilisp_blender.nrepl import server_start + +shutdown_fn = server_start(port=8889, interval_sec=0.05) +``` + +# Development + +This package uses the [Poetry tool](https://python-poetry.org/docs/) for managing development tasks. + +## Testing + +You can run tests using the following command: + +```bash +$ poetry run pytest +``` +### Integration testing + +To run integration tests, set the `$BB_BLENDER_TEST_HOME` environment variable to the root directory of the Blender installation where the development package is installed. See next section on how to facilitate the installation. + +```bash +$ export BB_BLENDER_TEST_HOME="~/blender420" +# or on MS-Windows +> $env:BB_BLENDER_TEST_HOME="c:\local\blender420" +``` +Then run the integration tests with + +```bash +$ poetry run pytest -m integration +``` + +### Installing Blender and the Development Package + +To download and install Blender in the directory specified by `$BB_BLENDER_TEST_HOME`, use: + +```bash +$ poetry run python scripts/blender_install.py 4.2.0 +``` + +To install the development version of the package at the same location, use: + +```bash +$ poetry build # build the package +$ poetry run python scripts/bb_install.py # install it in Blender +``` + diff --git a/examples/torus-pattern.png b/examples/torus-pattern.png new file mode 100644 index 0000000..a8576f8 Binary files /dev/null and b/examples/torus-pattern.png differ diff --git a/examples/torus_pattern.lpy b/examples/torus_pattern.lpy new file mode 100644 index 0000000..9a65884 --- /dev/null +++ b/examples/torus_pattern.lpy @@ -0,0 +1,54 @@ +(ns torus-pattern + "Creates a torus pattern with randomly colored materials." + (:import bpy + math)) + +(def object (.. bpy/ops -object)) +(def materials (.. bpy/data -materials)) +(def mesh (.. bpy/ops -mesh)) + +(defn clear-mesh-objects [] + (.select-all object ** :action "DESELECT") + (.select-by-type object ** :type "MESH") + (.delete object)) + +(clear-mesh-objects) + +(defn create-random-material [] + (let [mat (.new materials ** :name "RandomMaterial") + _ (set! (.-use-nodes mat) true) + bsdf (aget (.. mat -node-tree -nodes) "Principled BSDF")] + + (set! (-> bsdf .-inputs (aget "Base Color") .-default-value) + [(rand) (rand) (rand) 1]) + mat)) + +(defn create-torus [radius tube-radius location segments] + (.primitive-torus-add mesh ** + :major-radius radius + :minor-radius tube-radius + :location location + :major-segments segments + :minor-segments segments) + (let [obj (.. bpy/context -object) + material (create-random-material)] + (-> obj .-data .-materials (.append material)))) + +#_(create-torus 5, 5, [0 0 0] 48) + +(defn create-pattern [{:keys [layers-num radius tube-radius] + :or {layers-num 2 + radius 2 + tube-radius 0.2}}] + (let [angle-step (/ math/pi 4)] + (dotimes [i layers-num] + (let [layer-radius (* radius (inc i)) + objects-num (* 12 (inc i))] + (dotimes [j objects-num] + (let [angle (* j angle-step) + x (* layer-radius (math/cos angle)) + y (* layer-radius (math/sin angle)) + z (* i 0.5)] + (create-torus (/ radius 2) tube-radius [x y z] 48))))))) + +(create-pattern {:layers-num 5}) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..872f54f --- /dev/null +++ b/poetry.lock @@ -0,0 +1,632 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "astor" +version = "0.8.1" +description = "Read/rewrite/write Python ASTs" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +files = [ + {file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"}, + {file = "astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e"}, +] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "basilisp" +version = "0.1.0b2" +description = "A Clojure-like lisp written for Python" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "basilisp-0.1.0b2-py3-none-any.whl", hash = "sha256:0d49bce1be255984aa40d81cd3f460dabe6e079ee12f985c364115df1ea51f51"}, + {file = "basilisp-0.1.0b2.tar.gz", hash = "sha256:ab9b7ad12a253027191110aa630791656026a220c8d1c0b838399daf62bd05cf"}, +] + +[package.dependencies] +astor = {version = ">=0.8.1,<0.9.0", markers = "python_version < \"3.9\""} +attrs = ">=20.2.0" +immutables = ">=0.20,<1.0.0" +prompt-toolkit = ">=3.0.0,<4.0.0" +pyrsistent = ">=0.18.0,<1.0.0" +python-dateutil = ">=2.8.1,<3.0.0" +readerwriterlock = ">=1.0.8,<2.0.0" +typing-extensions = ">=4.7.0,<5.0.0" + +[package.extras] +pygments = ["pygments (>=2.9.0,<3.0.0)"] +pytest = ["pytest (>=7.0.0,<8.0.0)"] + +[[package]] +name = "black" +version = "24.4.2" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "immutables" +version = "0.20" +description = "Immutable Collections" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "immutables-0.20-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dea0ae4d7f31b145c18c16badeebc2f039d09411be4a8febb86e1244cf7f1ce0"}, + {file = "immutables-0.20-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2dd0dcef2f8d4523d34dbe1d2b7804b3d2a51fddbd104aad13f506a838a2ea15"}, + {file = "immutables-0.20-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:393dde58ffd6b4c089ffdf4cef5fe73dad37ce4681acffade5f5d5935ec23c93"}, + {file = "immutables-0.20-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1214b5a175df783662b7de94b4a82db55cc0ee206dd072fa9e279fb8895d8df"}, + {file = "immutables-0.20-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2761e3dc2a6406943ce77b3505e9b3c1187846de65d7247548dc7edaa202fcba"}, + {file = "immutables-0.20-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2bcea81e7516bd823b4ed16f4f794531097888675be13e833b1cc946370d5237"}, + {file = "immutables-0.20-cp310-cp310-win32.whl", hash = "sha256:d828e7580f1fa203ddeab0b5e91f44bf95706e7f283ca9fbbcf0ae08f63d3084"}, + {file = "immutables-0.20-cp310-cp310-win_amd64.whl", hash = "sha256:380e2957ba3d63422b2f3fbbff0547c7bbe6479d611d3635c6411005a4264525"}, + {file = "immutables-0.20-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:532be32c7a25dae6cade28825c76d3004cf4d166a0bfacf04bda16056d59ba26"}, + {file = "immutables-0.20-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5302ce9c7827f8300f3dc34a695abb71e4a32bab09e65e5ad6e454785383347f"}, + {file = "immutables-0.20-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b51aec54b571ae466113509d4dc79a2808dc2ae9263b71fd6b37778cb49eb292"}, + {file = "immutables-0.20-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f56aea56e597ecf6631f24a4e26007b6a5f4fe30278b96eb90bc1f60506164"}, + {file = "immutables-0.20-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:085ac48ee3eef7baf070f181cae574489bbf65930a83ec5bbd65c9940d625db3"}, + {file = "immutables-0.20-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f063f53b5c0e8f541ae381f1d828f3d05bbed766a2d6c817f9218b8b37a4cb66"}, + {file = "immutables-0.20-cp311-cp311-win32.whl", hash = "sha256:b0436cc831b47e26bef637bcf143cf0273e49946cfb7c28c44486d70513a3080"}, + {file = "immutables-0.20-cp311-cp311-win_amd64.whl", hash = "sha256:5bb32aee1ea16fbb90f58f8bd96016bca87aba0a8e574e5fa218d0d83b142851"}, + {file = "immutables-0.20-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ba726b7a3a696b9d4b122fa2c956bc68e866f3df1b92765060c88c64410ff82"}, + {file = "immutables-0.20-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5a88adf1dcc9d8ab07dba5e74deefcd5b5e38bc677815cbf9365dc43b69f1f08"}, + {file = "immutables-0.20-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1009a4e00e2e69a9b40c2f1272795f5a06ad72c9bf4638594d518e9cbd7a721a"}, + {file = "immutables-0.20-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96899994842c37cf4b9d6d2bedf685aae7810bd73f1538f8cba5426e2d65cb85"}, + {file = "immutables-0.20-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a606410b2ccb6ae339c3f26cccc9a92bcb16dc06f935d51edfd8ca68cf687e50"}, + {file = "immutables-0.20-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8e82754f72823085643a2c0e6a4c489b806613e94af205825fa81df2ba147a0"}, + {file = "immutables-0.20-cp312-cp312-win32.whl", hash = "sha256:525fb361bd7edc8a891633928d549713af8090c79c25af5cc06eb90b48cb3c64"}, + {file = "immutables-0.20-cp312-cp312-win_amd64.whl", hash = "sha256:a82afc3945e9ceb9bcd416dc4ed9b72f92760c42787e26de50610a8b81d48120"}, + {file = "immutables-0.20-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f17f25f21e82a1c349a61191cfb13e442a348b880b74cb01b00e0d1e848b63f4"}, + {file = "immutables-0.20-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:65954eb861c61af48debb1507518d45ae7d594b4fba7282785a70b48c5f51f9b"}, + {file = "immutables-0.20-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f8a7a22939278127b7a206d05679b268b9cf665437125625348e902617cbad"}, + {file = "immutables-0.20-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac86f4372f4cfaa00206c12472fd3a78753092279e0552b7e1880944d71b04fe"}, + {file = "immutables-0.20-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e771198edc11a9e02ffa693911b3918c6cde0b64ad2e6672b076dbe005557ad8"}, + {file = "immutables-0.20-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fc739fc07cff5df2e4f31addbd48660b5ac0da56e9f719f8bb45da8ddd632c63"}, + {file = "immutables-0.20-cp38-cp38-win32.whl", hash = "sha256:c086ccb44d9d3824b9bf816365d10b1b82837efc7119f8bab56bd7a27ed805a9"}, + {file = "immutables-0.20-cp38-cp38-win_amd64.whl", hash = "sha256:9cd2ee9c10bf00be3c94eb51854bc0b761326bd0a7ea0dad4272a3f182269ae6"}, + {file = "immutables-0.20-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4f78cb748261f852953620ed991de74972446fd484ec69377a41e2f1a1beb75"}, + {file = "immutables-0.20-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6449186ea91b7c17ec8e7bd9bf059858298b1db5c053f5d27de8eba077578ce"}, + {file = "immutables-0.20-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85dd9765b068f7beb297553fddfcf7f904bd58a184c520830a106a58f0c9bfb4"}, + {file = "immutables-0.20-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f349a7e0327b92dcefb863e49ace086f2f26e6689a4e022c98720c6e9696e763"}, + {file = "immutables-0.20-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e3a5462f6d3549bbf7d02ce929fb0cb6df9539445f0589105de4e8b99b906e69"}, + {file = "immutables-0.20-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cc51a01a64a6d2cd7db210a49ad010c2ac2e9e026745f23fd31e0784096dcfff"}, + {file = "immutables-0.20-cp39-cp39-win32.whl", hash = "sha256:83794712f0507416f2818edc63f84305358b8656a93e5b9e2ab056d9803c7507"}, + {file = "immutables-0.20-cp39-cp39-win_amd64.whl", hash = "sha256:2837b1078abc66d9f009bee9085cf62515d5516af9a5c9ea2751847e16efd236"}, + {file = "immutables-0.20.tar.gz", hash = "sha256:1d2f83e6a6a8455466cd97b9a90e2b4f7864648616dfa6b19d18f49badac3876"}, +] + +[package.extras] +test = ["flake8 (>=5.0,<6.0)", "mypy (>=1.4,<2.0)", "pycodestyle (>=2.9,<3.0)", "pytest (>=7.4,<8.0)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nrepl-python-client" +version = "0.0.3" +description = "A Python client for the nREPL Clojure networked-REPL server." +optional = false +python-versions = "*" +files = [ + {file = "nrepl-python-client-0.0.3.tar.gz", hash = "sha256:2e460e9b039f624478c26655a48f8d9e38a72c9a5396eb7c8de875bd75d25b79"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.47" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, + {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "pyrsistent" +version = "0.20.0" +description = "Persistent/Functional/Immutable data structures" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyrsistent-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34"}, + {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b"}, + {file = "pyrsistent-0.20.0-cp310-cp310-win32.whl", hash = "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f"}, + {file = "pyrsistent-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7"}, + {file = "pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a"}, + {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224"}, + {file = "pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656"}, + {file = "pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee"}, + {file = "pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3"}, + {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d"}, + {file = "pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174"}, + {file = "pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d"}, + {file = "pyrsistent-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714"}, + {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86"}, + {file = "pyrsistent-0.20.0-cp38-cp38-win32.whl", hash = "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423"}, + {file = "pyrsistent-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d"}, + {file = "pyrsistent-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022"}, + {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca"}, + {file = "pyrsistent-0.20.0-cp39-cp39-win32.whl", hash = "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f"}, + {file = "pyrsistent-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf"}, + {file = "pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b"}, + {file = "pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4"}, +] + +[[package]] +name = "pytest" +version = "8.3.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-xvfb" +version = "3.0.0" +description = "A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-xvfb-3.0.0.tar.gz", hash = "sha256:3746ab1f4d1159f03f751638d053689ccd284291b38b8fb03d3ebbe7bf69cfc0"}, + {file = "pytest_xvfb-3.0.0-py3-none-any.whl", hash = "sha256:352f247c788457ccdfcfeec8a47a2a6594c8eaf22f0302dae9e2635bb23975c2"}, +] + +[package.dependencies] +pytest = ">=2.8.1" +pyvirtualdisplay = ">=1.3" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyvirtualdisplay" +version = "3.0" +description = "python wrapper for Xvfb, Xephyr and Xvnc" +optional = false +python-versions = "*" +files = [ + {file = "PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e"}, + {file = "PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159"}, +] + +[[package]] +name = "readerwriterlock" +version = "1.0.9" +description = "A python implementation of the three Reader-Writer problems." +optional = false +python-versions = ">=3.6" +files = [ + {file = "readerwriterlock-1.0.9-py3-none-any.whl", hash = "sha256:8c4b704e60d15991462081a27ef46762fea49b478aa4426644f2146754759ca7"}, + {file = "readerwriterlock-1.0.9.tar.gz", hash = "sha256:b7c4cc003435d7a8ff15b312b0a62a88d9800ba6164af88991f87f8b748f9bea"}, +] + +[package.dependencies] +typing-extensions = "*" + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "fce4d017c3975a9674b28effa119c258434b4f6b9a71fbb38cc504a3f4df88e8" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..716a4ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "basilisp-blender" +version = "0.1.0" +description = "" +authors = ["ikappaki"] +readme = "README.md" +packages = [ + { include = "basilisp_blender", from = "src" }, +] + +[tool.poetry.dependencies] +python = "^3.8" +basilisp = "^0.1.0b2" + +[tool.poetry.group.test.dependencies] +pytest = "^8.3.1" +nrepl-python-client = "^0.0.3" + + +[tool.poetry.group.dev.dependencies] +pytest-xvfb = "^3.0.0" +requests = "^2.32.3" +black = "^24.4.2" +isort = "^5.13.2" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +markers = ["integration: integration tests"] +addopts = '-m "not integration"' \ No newline at end of file diff --git a/scripts/bb_package_install.py b/scripts/bb_package_install.py new file mode 100644 index 0000000..216c477 --- /dev/null +++ b/scripts/bb_package_install.py @@ -0,0 +1,47 @@ +"""Builds the package and installs in the Blender directory +returned by `dev.dev_utils.blender_home_get`, of which see. + +This direct directory is typically specified by the +`BB_BLENDER_TEST_HOME` environment variable. + +""" + +import os +import shutil +import subprocess +import sys +import tempfile + +from dev.dev_utils import blender_exec_path_get, file_exists_wait + +blender_path = blender_exec_path_get() + +result = subprocess.run(["poetry", "version"], capture_output=True, text=True) + +bb_version = result.stdout.strip().replace("-", "_").replace(" ", "-") +wheel_path = f"dist/{bb_version}-py3-none-any.whl" +print(f"\n:installing :version {bb_version} :wheel {wheel_path} :in {blender_path}\n") +assert os.path.exists(wheel_path), f":wheel-not-found {wheel_path}" + +with tempfile.TemporaryDirectory() as temp_dir: + temp_file = os.path.join(temp_dir, "install.py") + with open(temp_file, mode="w") as temp_file: + temp_file.write( + f'''import pip +pip.main(['install', {repr(wheel_path)}]) +from basilisp_blender import eval as evl + +evl.eval_str("""(import [pkg_resources :as pr]) +(println :basilisp-blender :installed + :version (str "basilisp_blender-" (.-version (pr/get_distribution "basilisp_blender"))))""") +''' + ) + + result = subprocess.run( + [blender_path, "--background", "--python", temp_file.name], + capture_output=True, + text=True, + ) + print(result.stderr) + print(result.stdout) + assert f":basilisp-blender :installed :version {bb_version}" in result.stdout diff --git a/scripts/blender_install.py b/scripts/blender_install.py new file mode 100644 index 0000000..afcb124 --- /dev/null +++ b/scripts/blender_install.py @@ -0,0 +1,108 @@ +"""Downloads the specified version of Blender (passed as the first +argument) to the output directory returned +by`dev.dev_utils.blender_home_get`, of which see. + +The directory is typically specified by the `BB_BLENDER_TEST_HOME` +environment variable. + +""" + +import os +import platform +import shutil +import sys + +import requests + +from dev.dev_utils import blender_home_get + +tmp_dir = ".bb-tmp" + +version = None +outpath = None +assert len(sys.argv) == 2, "usage: blender_install.py " + +(version,) = tuple(sys.argv)[1:] +print(f":args :version {version}") +assert version.count(".") == 2, f":error :expected-two-dots-in-version {version}" + +version_short = version[: version.rfind(".")] + +outdir = blender_home_get() +print(f":destination {outdir}") + +system = platform.system() + +filename = None +extension = None +if system == "Windows": + filename = f"blender-{version}-windows-x64.zip" + extension = ".zip" +elif system == "Linux": + filename = f"blender-{version}-linux-x64.tar.xz" + extension = ".tar.xz" +elif system == "Darwin": + filename = f"blender-{version}-macos-arm64.dmg" + extension = ".dmg" + +assert filename, f":error :system-unsupported {system}" +assert extension + +filename_path = os.path.join(tmp_dir, filename) +url = f"https://download.blender.org/release/Blender{version_short}/{filename}" +outdir_abs = os.path.abspath(os.path.expanduser(outdir)) +assert not os.path.exists(outdir_abs), f":error :outdir {outdir_abs} :exists-already" + +os.makedirs(tmp_dir, exist_ok=True) + +print(f"\n:downloading {url} :to {filename_path}") + +with requests.get(url, stream=True) as response: + response.raise_for_status() + with open(filename_path, "wb") as file: + for chunk in response.iter_content(chunk_size=1024 * 1024 * 10): + print("*", end="", flush=True) + file.write(chunk) + +print(f"\n:download :done") +assert os.path.exists(filename_path) + +if system == "Darwin": + extract_base = "/Volumes" + extract_dir = "/Volumes/Blender/Blender.app" +else: + extract_base = tmp_dir + extract_dir = os.path.join(tmp_dir, filename[: filename.rfind(extension)]) + +print(f"\n:extracting {filename_path} :to {extract_base} :as {extract_dir}") +if system == "Windows": + import zipfile + + with zipfile.ZipFile(filename_path) as zip_ref: + zip_ref.extractall(extract_base) +elif system == "Linux": + import tarfile + + with tarfile.open(filename_path) as tar: + tar.extractall(path=extract_base) +elif system == "Darwin": + import subprocess + + result = subprocess.run( + ["hdiutil", "attach", filename_path], check=True, capture_output=True, text=True + ) + print(f":process :stdout {result.stdout}") + print(f":process :stderr {result.stderr}") + extract_dir = "/Volumes/Blender/Blender.app" +print(f":extract :done") +assert os.path.exists(extract_dir) + +if system == "Darwin": + print(f"\n:copying {extract_dir} :to {outdir_abs}") + shutil.copytree(extract_dir, outdir_abs) + print(f":copying :done") +else: + print(f"\n:moving {extract_dir} :to {outdir_abs}") + shutil.move(extract_dir, outdir_abs) + print(f":move :done") +assert outdir_abs diff --git a/src/basilisp_blender/__init__.py b/src/basilisp_blender/__init__.py new file mode 100644 index 0000000..80f389b --- /dev/null +++ b/src/basilisp_blender/__init__.py @@ -0,0 +1,29 @@ +"""Initialize the Basilisp runtime environment.""" + +import logging + +from basilisp import main as basilisp +from basilisp.lang import compiler + +COMPILER_OPTS = compiler.compiler_opts() +basilisp.init(COMPILER_OPTS) + +LOGGER = logging.getLogger("basilisp-blender") +LOGGER.addHandler(logging.StreamHandler()) + + +def log_level_set(level, filepath=None): + """Sets the logger in the `LOGGER` global variable to the + specified `level`. + + If an optional `filepath` is provided, logging will also be + written to that file. + + """ + LOGGER.setLevel(level) + if filepath: + file_handler = logging.FileHandler(filepath, mode="w") + LOGGER.addHandler(file_handler) + + +# log_level_set(logging.DEBUG, "basilisp-blender.log") diff --git a/src/basilisp_blender/eval.py b/src/basilisp_blender/eval.py new file mode 100644 index 0000000..192a7c1 --- /dev/null +++ b/src/basilisp_blender/eval.py @@ -0,0 +1,48 @@ +"""Functions for evaluating Basilisp code.""" + +from basilisp import cli +from basilisp import main as basilisp +from basilisp.lang import compiler, runtime + +from basilisp_blender import COMPILER_OPTS + +# the namesapce where the command will be evaluated at +EVALUATION_NS_ = "blender-user" + +CTX_ = compiler.CompilerContext(filename="blender", opts=COMPILER_OPTS) +NS_VAR_ = runtime.set_current_ns(EVALUATION_NS_) +EOF_ = object() + + +def eval_str(code): + """Evaluate the given `code` string in Basilisp and return the + result. + + """ + return cli.eval_str(code, CTX_, NS_VAR_.value, EOF_) + + +def eval_file(filepath): + """Evaluate the Basilisp code from the file specified by + `filepath`. + + """ + return cli.eval_file(filepath, CTX_, NS_VAR_.value) + + +# Set up the Basilisp namespace for command evaluation +eval_str(f"(ns {EVALUATION_NS_} (:require clojure.core))") + +try: + import bpy + + def eval_editor(text_block): + """Evaluate the Basilisp code contained in the specified + Blender Text Editor `text_block` and return the result. + + """ + code = bpy.data.texts[text_block].as_string() + return eval_str(code) + +except ImportError: + pass diff --git a/src/basilisp_blender/nrepl.py b/src/basilisp_blender/nrepl.py new file mode 100644 index 0000000..88fe7fd --- /dev/null +++ b/src/basilisp_blender/nrepl.py @@ -0,0 +1,103 @@ +"""Functions that depend on the `bpy` module.""" + +import atexit +import importlib +import sys + +from basilisp.lang import keyword as kw +from basilisp.lang import map as lmap +from basilisp.lang.util import munge + + +def server_thread_async_start(host="127.0.0.1", port=0, nrepl_port_filepath=None): + """Start an nREPL server on the specified `host` and `port` on a + separate thread. + + The server binds to "127.0.0.1" by default and uses a random port + if `port` is set to 0 (the default). + + Client requests are queued rather than executed immediately. The + function returns two callables: one for processing the queued + requests, and another for shutting down the server. + + The port number is saved to the `nrepl_port_filepath` for nREPL + clients to use, if provided. + + """ + assert '"' not in host + assert port >= 0 + + nrepl_server_mod = importlib.import_module(munge("basilisp-blender.nrepl-server")) + ret = nrepl_server_mod.server_thread_async_start__BANG__( + lmap.map( + { + kw.keyword("host"): host, + kw.keyword("port"): port, + kw.keyword("nrepl-port-file"): nrepl_port_filepath, + } + ) + ) + + assert ret is not None, ":server-error :could-not-be-started" + work_fn = ret.get(kw.keyword("work-fn")) + shutdown_fn = ret.get(kw.keyword("shutdown-fn")) + assert work_fn and shutdown_fn, ":server-error :could-not-be-started" + + return work_fn, shutdown_fn + + +try: + import bpy + + def server_start( + host="127.0.0.1", port=0, nrepl_port_filepath=".nrepl-port", interval_sec=0.1 + ): + """Start an nREPL server on a separate thread using the + specified `host` and `port`. The server binds to "127.0.0.1" + by default and uses a random port if `port` is set to 0 (the + default). Client requests are queued and executed at intervals + defined by `interval_sec` (defaulting to 0.1 seconds) using a + `bpy.app.timers` timer for thread safety. The server is also + registered to shut down upon program exit. + + The port number is saved to a file for nREPL clients to use. By + default, this is an `.nrepl-port` file in the current working + directory. If `nrepl_port_filepath` is provided, the port number is + written to the specified filepath instead. + + """ + + def work_do_safe(workfn, interval_sec): + """Execute `workfn` return, `interval_sec` to indicate when to + call this function again, and catch exceptions to report errors to + stderr. + + """ + try: + workfn() + except Exception as e: + print(f":nrepl-work-fn-error {e}", file=sys.stderr) + return interval_sec + + def shutdown_safe(shutdownfn): + """Execute `shutdownfn` and handle any exceptions by reporting + errors to stderr. + + """ + try: + shutdownfn() + except Exception as e: + print(f":nrepl-shutdown-error {e}", file=sys.stderr) + + workfn, shutdownfn = server_thread_async_start( + host=host, port=port, nrepl_port_filepath=nrepl_port_filepath + ) + + atexit.register(lambda: shutdown_safe(shutdownfn)) + + bpy.app.timers.register(lambda: work_do_safe(workfn, interval_sec)) + + return shutdownfn + +except ImportError: + pass diff --git a/src/basilisp_blender/nrepl_server.lpy b/src/basilisp_blender/nrepl_server.lpy new file mode 100644 index 0000000..2bc5f45 --- /dev/null +++ b/src/basilisp_blender/nrepl_server.lpy @@ -0,0 +1,585 @@ +;; adapted from +;; https://github.com/basilisp-lang/basilisp/blob/b4d9c2d6ed1aaa9ba2f4b1dc0e8073813aab1315/src/basilisp/contrib/nrepl_server.lpy +(ns basilisp-blender.nrepl-server + "A port of `nbb `_'s nREPL server implementation to basilisp. + + Additions: + + - Client requests can be collected into a map for asynchronous + processing outside the current execution thread." + (:require [basilisp.contrib.bencode :as bc] + [basilisp.string :as str]) + (:import logging + queue + socketserver + sys + threading + traceback + uuid)) + +(def logger + "The logger for this namespace." + (logging/getLogger (namespace ::))) + +(defmacro ^:private debug [& values] + `(when (.isEnabledFor logger logging/DEBUG) + (.debug logger (str/join " " [~@values])))) +(defmacro ^:private info [& values] + `(when (.isEnabledFor logger logging/INFO) + (.info logger (str/join " " [~@values])))) +(defmacro ^:private warn [& values] + `(when (.isEnabledFor logger logging/WARNING) + (.warning logger (str/join " " [~@values])))) +(defmacro error [& values] + `(.error logger (str/join " " [~@values]))) + +(definterface ^:private IStdOut + ;; Pythonic interface for creating `sys/stdout` like File objects. + (flush []) + (write [value])) + +(deftype ^:private StreamOutFn [out-fn] + ;; A type to use as replacement binding for writing to `sys/stdout` + ;; stream, so that the output ``value`` is passed to ``out-fn`` + ;; instead. + IStdOut + (flush [_self] + nil) + (write [_self value] + (out-fn value))) + +(defn- response-for-mw [handler] + (fn [{:keys [id session] :as request} response] + (let [response (cond-> (assoc response + "id" id) + session (assoc "session" session))] + (handler request response)))) + +(defn- coerce-request-mw [handler] + (fn [request send-fn] + (handler (update request :op keyword) send-fn))) + +(defn- log-request-mw [handler] + (fn [request send-fn] + (info :request (dissoc request :client*)) + (handler request send-fn))) + +(defn- log-response-mw [handler] + (fn [request response] + (info :response response) + (handler request response))) + +(declare ops) + +(defn- handle-describe [request send-fn] + (send-fn request + {"versions" {"basilisp" (let [version basilisp.lang.runtime/BASILISP-VERSION-STRING] + (assoc (zipmap ["major" "minor" "incremental"] + version) + "version-string" (str/join "." version))) + "python" (let [version (get (.split sys/version " ") 0)] + (assoc (zipmap ["major" "minor" "incremental"] + (py->lisp (.split version "."))) + "version-string" (py->lisp sys/version)))} + "ops" (zipmap (map name (keys ops)) (repeat {})) + "status" ["done"]})) + +(defn- format-value [_nrepl-pprint _pprint-options value] + (pr-str value)) + +(defn- send-value [request send-fn v] + (let [{:keys [client*]} request + {:keys [*1 *2]} @client* + [v opts] v + ns (:ns opts)] + (swap! client* assoc :*1 v :*2 *1 :*3 *2) + (let [v (format-value (:nrepl.middleware.print/print request) + (:nrepl.middleware.print/options request) + v)] + (send-fn request {"value" (str v) + "ns" (str ns)})))) + +(defn- handle-error [send-fn request e] + (let [{:keys [client* ns]} request + data (ex-data e) + message (or (:message data) (str e))] + (swap! client* assoc :*e e) + (send-fn request {"err" (str message)}) + (send-fn request {"ex" (traceback/format-exc) + "status" ["eval-error"] + "ns" ns}))) + +(defn- do-handle-eval + "Evaluate the ``request`` ``code`` of ``file`` in the ``ns`` namespace + according to the current state of the ``client*`` and sends its + result with ``send-fn``. + + The result sent is either the last evaluated value or exception, + followed by the \"done\" status. + + If ``ns`` is not provided, then it uses the ``client``'s :eval-ns as + the evaluation namespace. The latter is updated with the current + namespace after evaluation is completed. + + It binds the `*1`, `*2`, `*3` and `*e` variables for evaluation from + the corresponding ones found in ``client*``, and updates the latter + according to the result." + [{:keys [client* code ns file _column _line] :as request} send-fn] + (let [{:keys [*1 *2 *3 *e eval-ns]} @client* + out-stream (StreamOutFn #(send-fn request {"out" %})) + reader (io/StringIO code) + ctx (basilisp.lang.compiler.CompilerContext. (or file "")) + eval-ns (if ns + (create-ns (symbol ns)) + eval-ns)] + (binding [*ns* eval-ns + *out* out-stream + *1 *1 *2 *2 *3 *3 *e *e] + (try + (let [results (for [form (seq (basilisp.lang.reader/read reader + *resolver* + *data-readers*))] + (basilisp.lang.compiler/compile-and-exec-form form + ctx + *ns*)) + result (last results)] + (send-value request send-fn [result {:ns (str *ns*)}])) + (catch python/Exception e + (info :eval-exception e) + (handle-error send-fn (assoc request :ns (str *ns*)) e)) + (finally + (swap! client* assoc :eval-ns *ns*) + (send-fn request {"ns" (str *ns*) + "status" ["done"]})))))) + +(defn- handle-eval [request send-fn] + (do-handle-eval request send-fn)) + +(defn- handle-clone [request send-fn] + (send-fn request {"new-session" (str (random-uuid)) + "status" ["done"]})) + +(defn- handle-close [request send-fn] + (send-fn request {"status" ["done"]})) + +(defn- handle-classpath [_request _send-fn] + (throw (python/NotImplementedError))) + +(defn- handle-macroexpand [_request _send-fn] + (throw (python/NotImplementedError))) + +(defn- symbol-identify + "Return a vector of information about ``symbol-str`` as might be + resolved in ``resolve-ns``. + + The returned vector can be one of + + [:keyword KEYWORD] the ``symbol-str`` is this KEYWORD. + + [:nil FORM] the ``symbol-str`` is this nil FORM. + + [:special-form FORM] the ``symbol-str`` this special FORM. + + [:var VAR] the ``symbol-str`` is this VAR. + + [:error ERROR] there was this ERROR when trying to parse ``symbol-str``. + + [:other FORM] the ``symbol-str`` is of yet to be categorized FORM." + [resolve-ns symbol-str] + (let [reader (io/StringIO symbol-str) + {:keys [form error]} (try {:form (binding [*ns* resolve-ns] + (first (seq (basilisp.lang.reader/read reader + *resolver* + *data-readers*))))} + (catch python/Exception e + (info :symbol-identify-reader-error :input symbol-str :exception e) + {:error (str e)}))] + + (cond + error + [:error error] + + (nil? form) + [:nil form] + + (keyword? form) + [:keyword form] + + (special-symbol? form) + [:special-form form] + + :else + (let [{:keys [var error]} (try {:var (ns-resolve resolve-ns form)} + (catch python/Exception e + {:error (str e)}))] + (cond + var + [:var var] + error + [:error error] + :else + [:other form]))))) + +(defn- forms-join [forms] + (->> (map pr-str forms) + (str/join \newline))) + +(defn- handle-lookup + "Look up :sym (CIDER) or :symbol (calva) from ``request`` in + ``ns`` (or if not provided :eval-ns from ``client*``) and pass + results to ``send-fn``. + + Serves both :eldoc and :info ``request`` :op's." + [{:keys [ns client*] :as request} send-fn] + (let [mapping-type (-> request :op) + {:keys [eval-ns]} @client*] + (try + (let [lookup-ns (if ns + (create-ns (symbol ns)) + eval-ns) + sym-str (or (:sym request) ;; cider + (:symbol request) ;; calva + ) + [tp var-maybe] (symbol-identify lookup-ns sym-str) + var-meta (when (= tp :var) (meta var-maybe)) + {:keys [arglists doc file ns line] symname :name} var-meta + ref (when (= tp :var) (var-get var-maybe)) + response (when symname (case mapping-type + :eldoc (cond-> + {"eldoc" (mapv #(mapv str %) arglists) + "ns" (str ns) + "type" (if (fn? ref) + "function" + "variable") + "name" (str symname) + "status" ["done"]} + doc (assoc "docstring" doc)) + :info {"doc" doc + "ns" (str ns) + "name" (str symname) + "file" file + "line" line + "arglists-str" (forms-join arglists) + "status" ["done"]})) + status (if (and (nil? symname) (= mapping-type :eldoc) ) + ["done" "no-eldoc"] + ["done"])] + (debug :lookup :sym sym-str :doc doc :args arglists) + (send-fn request (assoc response :status status))) + (catch python/Exception e + (let [status (cond-> + ["done"] + (= mapping-type :eldoc) + (conj "no-eldoc"))] + (send-fn + request + {"status" status "ex" (str e)})))))) + +(defn- handle-load-file + "Evaluate code in ``file`` from ``file-path`` and sends the result + using the ``send-fn``." + [{:keys [file _file-name file-path] :as request} send-fn] + (do-handle-eval (assoc request + :file (or file-path "") + :code file) + send-fn)) + +(defn- handle-complete + "Calculates the name completion candidates for ``prefix`` (or + ``req-symbol``) in namespace ``ns`` for ``client*`` and sends the + completions using ``send-fn``. + + If ``ns`` is not provided, then the ``client*`` :eval-ns is used + instead." + [{:keys [client* ns prefix] req-symbol :symbol :as request} send-fn] + (let [prefix (or prefix req-symbol) + {:keys [eval-ns]} @client* + completion-ns (if ns + (create-ns (symbol ns)) + eval-ns) + completions (when-not (str/blank? prefix) + (seq (binding [*ns* completion-ns] + (basilisp.lang.runtime/repl_completions prefix))))] + (send-fn request {"completions" (->> (map str completions) + sort + (map (fn [completion] + (let [[tp var-maybe] (symbol-identify completion-ns completion)] + (merge {:candidate completion} + (cond + (some #{tp} {:keyword :special-form}) + {:type (name tp)} + (= tp :var) + (let [{:keys [ns macro]} (meta var-maybe) + ref (var-get var-maybe) + ref-tp (cond + macro "macro" + (fn? ref) "function" + :else "var")] + {:ns (str ns) + :type ref-tp}) + :else + {:candidate completion}))))) + vec) + "status" ["done"]}))) + +(def ops + "A list of operations supported by the nrepl server." + {:eval handle-eval + :describe handle-describe + :info handle-lookup + :eldoc handle-lookup + :clone handle-clone + :close handle-close + ;; :macroexpand handle-macroexpand + ;; :classpath handle-classpath + :load-file handle-load-file + :complete handle-complete + }) + +(defn- handle-request [{:keys [op] :as request} send-fn] + (if-let [op-fn (get ops op)] + (op-fn request send-fn) + (do + (warn "Unhandled operation" op) + (send-fn request {"status" ["error" "unknown-op" "done"]})))) + +(defn- make-request-handler [_] + (-> handle-request + coerce-request-mw + log-request-mw)) + +(defn- make-send-fn [socket] + (fn [_request response] + (debug :sending (:id _request) :response-keys (keys response)) + (try + (.sendall socket (bc/encode response)) + (catch python/TypeError e + (error :bencode-cannot-decode (pr-str e)))))) + +(defn- make-reponse-handler [socket] + (-> (make-send-fn socket) + log-response-mw + response-for-mw)) + +(defn- on-connect! [tcp-req-handler opts] + "Serve a new nREPL connection as found in ``tcp-req-handler`` according to ``opts``. + + ``opts`` is a map of options with the following optional keys. + + :recv-buffer-size The buffer size to using for incoming nREPL + messages. + + :work* An optional atom containing a map. If provided, client + requests are queued in the map under a client information key, + rather than being executed imediately." + (let [{:keys [recv-buffer-size work*] + :or {recv-buffer-size 1024}} opts + socket (.-request tcp-req-handler) + handler (make-request-handler opts) + response-handler (make-reponse-handler socket) + pending (atom nil) + zero-bytes #b "" + client-info (py->lisp (.getsockname socket)) + client* (atom {:*1 nil :*2 nil ;; keeps track of the latest + :*3 nil :*e nil ;; evaluation results + :eval-ns nil ;; the last eval ns + }) + reqq (when work* (queue/Queue))] + (when work* + (swap! work* assoc client-info reqq)) + (try + (info "Connection accepted" :info client-info) + ;; Need to load the `clojure.core` alias because cider uses it + ;; to test for availability of features. + (eval (read-string "(ns user (:require clojure.core))")) + (swap! client* assoc :eval-ns *ns*) + (loop [data (.recv socket recv-buffer-size)] + (if (= data zero-bytes) + (do (info :socket-closing client-info) + (.close socket)) + (let [data (if-let [p @pending] + (let [b (+ p data)] + (reset! pending nil) + b) + data) + [requests unprocessed] (bc/decode-all data {:keywordize-keys true + :string-fn #(.decode % "utf-8")})] + (debug :requests requests) + (when (not (str/blank? unprocessed)) + (reset! pending unprocessed)) + (doseq [request requests] + (let [req-do #(try + (handler (assoc request :client* client*) response-handler) + (catch python/Exception e + (error :request-handler-unexpected-exception (pr-str e))))] + (if reqq + (.put reqq req-do) + (req-do)))) + (recur (.recv socket recv-buffer-size))))) + (catch python/Exception e + (error :client-connection-error :client client-info :exception e) + (error (traceback/format-exc)))))) + +(defn clients-work-do! + "Execute all client requests contained in the `work*` atom and call + the optional `notify-fn` with the client information and the + request to be executed. + + `work*` is an atom containing a map where each key represents a + client information object, and each value is a `queue.Queue` of + request functions to be executed. + + The optional `notify-fn` argument is a 2-arity function that will be + called with the client information and the request that is about to + be executed." + ([work*] + (clients-work-do! work* nil)) + ([work* notify-fn] + (let [work @work*] + (doseq [[client-info reqq] work] + (while (not (.empty reqq)) + (debug ":async-work-req-do" :client client-info :qsize (.qsize reqq)) + (let [req (.get reqq)] + (when notify-fn (notify-fn client-info req)) + (req) + (debug ":async-work-req-done"))))))) + +(defn server-make + "Create and return a `socketserver/TCPServer` serving nREPL clients + according to ``opts`` at 127.0.0.1 at a random available port. + + See `ops` for the operation supported by the server. + + The nREPL starts at the `user` namespace and binds `*1`, `*2`, `*3` + and `*e` to the ultimate, penultimate, antepenultimate evaluation + result and last exception message respectively. + + ``opts`` is a map of options with the following optional keys + + :host The host address to bind to, defaults to 127.0.0.1. + + :port The port number to listen to, defaults to 0 which means to + pickup a random available port. + + See :lpy:fn:`on-connect!` for additionally supported ``opts`` keys. + + Known limitations: + + 1. All client connections share the same environment at the moment, + which is the env that the server runs in. This could change in the + future to isolate the clients interactions from each other. + + 2. The session uuids are ignored and only created to satisfy the + initial clone op." + [opts] + (let [{:keys [host port] :or {host "127.0.0.1" port 0}} opts + handler (python/type (name (gensym "nREPLTCPHandler")) (python/tuple [socketserver/StreamRequestHandler]) + #py {"handle" #(on-connect! % opts)})] + (socketserver/ThreadingTCPServer (python/tuple [host port]) handler))) + +(defn server-shutdown! + "Shutdown the `server`." + [server] + (info ":server-shutting-down") + (.shutdown server) + (info ":server-closing") + (.server-close server) + (info ":server-closed")) + +(def ^:private nrepl-server-signature + "The de facto signature nrepl started message that is used by IDEs to + identify the host and port number the server is running on." + "nREPL server started on port %s on host %s - nrepl://%s:%s") + +(defn start-server! + "Create an nREPL server with :lpy:fn:`server-make` (of which see) + according to ``opts`` if given, and serve clients for ever. + + It prints out the `nrepl-server-signature` message at startup for + IDEs to pickup the host number to connect to. + + ``opts`` is a map of options with the following optional keys + + + :async? If true, runs the server in asynchronous where requests are + queued rather than executed immediately. This mode requires that + the :server* key is present in the ``opts`` + map. The :server*'s :work-fn key will be set to a function that + processes all pending work (see :atoms* below). + + :nrepl-port-file An option filepath to write the port number + to. This is typically set to .nrepl-port for the editors to pick up. + + :server* A map atom. The map may contains an optional :start-event + key with a `threading.Event` value. This event is set when the + server is about to enter its main loop. The map will be populated + with the following keys + + :server Set to the server reference. + + :work-fn When in `async?` mode, set to a function that executes all pending work. + + :shutdown-fn set to a function that can be called to shut down the server. + + also see :lpy:fn:`server-make` for additionally supported + ``opts`` keys." + ([] + (start-server! {})) + ([opts] + (let [{:keys [nrepl-port-file async? server*] + :or {nrepl-port-file ".nrepl-port"}} opts + work* (when async? (atom {})) + server (server-make (assoc opts :work* work*)) + {:keys [start-event]} @server*] + (if (and async? (not server*)) + (error "Error: async server requires the server* option.") + + (try + (when server* (reset! server* (cond-> {:server server + :start-event start-event + :shutdown-fn #(server-shutdown! server)} + async? + (assoc :work-fn (partial clients-work-do! work*))))) + (let [[host port] (py->lisp (.-server-address server))] + (binding [*out* sys/stdout] + (println (format nrepl-server-signature port host host port))) + (spit nrepl-port-file (str port))) + (when start-event (.set start-event)) + (.serve-forever server) + (catch python/KeyboardInterrupt _e + (println "Exiting in response to a keyboard interrupt...")) + (catch python/Exception e + (error :nrepl-server-error e) + (error (traceback/format-exc)))))))) + + +(defn server-thread-async-start! + "Start an server process in a daemon thread, where client requests are + queued for differed execution by a work function, rather than + executed immediately. + + ``opts`` support the same keys as lpy:fn:``start-server!`` (of which + see), except for the :async?, :server* and :start-events keys, which + will be overwriten during execution. + + On success, returns a map of + + :server The server reference. + + :server-thread The server thread. + + :shutdown-fn The function to call to shut down the server. + + :work-fn The function to call for executing any pending work." + [opts] + (let [start-event (threading/Event) + server* (atom {:start-event start-event}) + opts (assoc opts :async? true :server* server*) + start-fn #(start-server! opts) + thread (threading/Thread ** :target start-fn :daemon true)] + (.start thread) + (if (.wait start-event 10) + (-> (select-keys @server* [:server :work-fn :shutdown-fn]) + (assoc :server-thread thread)) + + (error :server-thread-async-start :could-not-start)))) diff --git a/src/dev/dev_utils.py b/src/dev/dev_utils.py new file mode 100644 index 0000000..86c7c33 --- /dev/null +++ b/src/dev/dev_utils.py @@ -0,0 +1,59 @@ +"Development utils shared amongst scripts and tests, but excluded from the package." +import os +import platform +import shutil +import time + +ENV_BLENDER_HOME_ = "BB_BLENDER_TEST_HOME" + + +def file_exists_wait(filepath, count_max, interval_sec): + """Checks for the existence of `filepath` in a loop, waiting for + `interval_sec` seconds between checks. The loop continues until + `filepath` is found or `count_max` iteration are reached. + + """ + while count_max > 0: + if os.path.exists(str(filepath)) and os.path.getsize(filepath) > 0: + break + count_max -= 1 + time.sleep(interval_sec) + + +def blender_home_get(): + """Returns the absolute path to the Blender home directory, as + specified in the environment variable pointed by + `ENV_BLENDER_HOME_`. + + `assert`s that the path exists. + + """ + blender_home = os.getenv(ENV_BLENDER_HOME_) + assert blender_home, f":error :env-var-not-set {ENV_BLENDER_HOME_}" + blender_home_abs = os.path.abspath(os.path.expanduser(blender_home)) + return blender_home_abs + + +def blender_exec_path_get(): + """Returns the path to the Blender executable in the blender home + path obtained from `blender_home_get`, or None if the executable + is not found. + + """ + blender_home_abs = blender_home_get() + if platform.system() == "Darwin": + blender_home_abs = os.path.join(blender_home_abs, "Contents/MacOS") + + exec_path = None + envpath = os.environ.get("PATH", "") + envpath_new = blender_home_abs + try: + os.environ["PATH"] = envpath_new + exec_path = shutil.which("blender") + finally: + os.environ["PATH"] = envpath + assert ( + exec_path + ), f":error :blender-exec-not-found-in {ENV_BLENDER_HOME_}={blender_home_abs}" + print(f":blender-found-at {ENV_BLENDER_HOME_}={blender_home_abs} :exec {exec_path}") + return exec_path diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/basilisp_blender/__init__.py b/tests/basilisp_blender/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/basilisp_blender/eval_test.py b/tests/basilisp_blender/eval_test.py new file mode 100644 index 0000000..db056d5 --- /dev/null +++ b/tests/basilisp_blender/eval_test.py @@ -0,0 +1,37 @@ +import os +import tempfile +from pathlib import Path + +import pytest + +from basilisp_blender import eval as evl + + +@pytest.mark.parametrize( + "r,code", + [ + (3, "(+ 1 2)"), + ], +) +def test_eval_str(r, code: str): + assert r == evl.eval_str(code) + + +@pytest.mark.parametrize( + "result,code", + [ + (":result 7", "(+ 4 3)"), + ], +) +def test_eval_file(capsys, result, code): + temp = tempfile.NamedTemporaryFile( + delete=False, prefix="basilispblendertest", mode="w" + ) + try: + temp.write(f'(import sys)(.write sys/stdout (str :result " " {code}))') + temp.close() + evl.eval_file(Path(temp.name).as_posix()) + captured = capsys.readouterr() + assert captured.out == result + finally: + os.remove(temp.name) diff --git a/tests/basilisp_blender/integration/__init__.py b/tests/basilisp_blender/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/basilisp_blender/integration/int_eval_test.py b/tests/basilisp_blender/integration/int_eval_test.py new file mode 100644 index 0000000..c9a55d3 --- /dev/null +++ b/tests/basilisp_blender/integration/int_eval_test.py @@ -0,0 +1,28 @@ +import pytest + +from tests.basilisp_blender.integration import test_utils as tu + +pytestmark = pytest.mark.integration + + +def test_eval_editor(): + result = tu.blender_eval( + """from basilisp_blender import eval as evl +import bpy +before = 0 +for obj in bpy.data.objects: + if obj.name.startswith("Suzanne"): + before += 1 + +block = bpy.data.texts.new(name="basilisp-blender-test") +block.write("(import bpy) (-> bpy/ops .-mesh (.primitive_monkey_add ** :location [0,0,0]))") +evl.eval_editor("basilisp-blender-test") + +after = 0 +for obj in bpy.data.objects: + if obj.name.startswith("Suzanne"): + after += 1 +print(f":result :before {before} :after {after}") +""" + ) + assert ":result :before 0 :after 1" in result.stdout diff --git a/tests/basilisp_blender/integration/int_nrepl_test.py b/tests/basilisp_blender/integration/int_nrepl_test.py new file mode 100644 index 0000000..5e4e4b2 --- /dev/null +++ b/tests/basilisp_blender/integration/int_nrepl_test.py @@ -0,0 +1,81 @@ +import os +import threading + +import nrepl as nrepl_client +import pytest + +import tests.basilisp_blender.integration.test_utils as tu + +pytestmark = pytest.mark.integration + + +@pytest.mark.skipif( + os.getenv("RUNNER_OS", "Linux") != "Linux", + reason="GHA UI testing is only supported on Linux.", +) +def test_server_start(tmp_path): + codefile = tmp_path / "server-start-code-file.py" + portfile = tmp_path / ".basilisp-blender-int-test-port" + logfile = tmp_path / "basilisp-blender-int-server-start.log" + print(f":logging-to {logfile}") + with open(codefile, "w") as file: + file.write( + f"""from basilisp_blender import nrepl, log_level_set +import logging +import sys +print(":start") +sys.stdout.flush() +log_level_set(logging.DEBUG, {repr(str(logfile))}) +logging.debug(":begin") +shutdownfn = nrepl.server_start(nrepl_port_filepath={repr(str(portfile))}) +logging.debug(":end") +""" + ) + + process = None + try: + process = tu.blender_eval_file(codefile) + tu.file_exists_wait(portfile, 10, 0.5) + assert os.path.exists(str(portfile)) + + port = None + with open(portfile, "r") as file: + content = file.read().strip() + port = int(content) + + assert isinstance(port, int) and port > 0 + + print(f":port {port}") + + def nrepl_client_test(): + client = None + try: + client = nrepl_client.connect(f"nrepl://localhost:{port}") + client.write({"id": 1, "op": "clone"}) + result = client.read() + assert "status" in result and result["status"] == ["done"] + client.write({"id": 2, "op": "eval", "code": "(reduce + (range 20))"}) + result = client.read() + assert "value" in result and result["value"] == "190" + except Exception as e: + assert e is None + finally: + if client: + client.close() + + client_thread = threading.Thread(target=nrepl_client_test, daemon=True) + client_thread.start() + client_thread.join(timeout=20) + assert not client_thread.is_alive() + process.terminate() + out, error = process.communicate() + assert "nREPL server started on port" in out + finally: + if process: + process.terminate() + out, error = process.communicate() + print(f"::process :error {error}") + print(f"::process :out {out}") + if os.path.exists(str(logfile)): + with open(logfile, "r") as file: + print(f":lpy-log-contents {file.read()}") diff --git a/tests/basilisp_blender/integration/test_utils.py b/tests/basilisp_blender/integration/test_utils.py new file mode 100644 index 0000000..3bd45af --- /dev/null +++ b/tests/basilisp_blender/integration/test_utils.py @@ -0,0 +1,142 @@ +"Integration test utils." +import os +import subprocess +import tempfile +import time + +import pytest + +from dev.dev_utils import blender_exec_path_get, file_exists_wait + +pytestmark = pytest.mark.integration + + +def blender_run(*args, background=False): + """Executes the Blender executable located using the + `blender_exec_path_get` function, in a subprocess with the + provided `args` command line arguments. + + It waits for the subprocess to complete and returns the result of + `subprocess.run`, of which see. + + If the `background` keyword argument is True (default is False), + the subprocess is run in the background with its stdin, stdout and + stderr redirect to pipes. In this case, the function returns the + results of `subprocess.Popen`, of which see. + + """ + bp = blender_exec_path_get() + assert bp is not None + cmd_args = (bp,) + args + result = None + if background: + result = subprocess.Popen( + cmd_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + else: + result = subprocess.run(cmd_args, capture_output=True, text=True) + return result + + +def blender_eval(code): + """Executes the Python `code` in a Blender subprocess created with + `blender_run` and returns its result. + + """ + fd, path = tempfile.mkstemp(suffix=".py", prefix="basilisp-blender-test_") + try: + with os.fdopen(fd, "w") as temp_file: + temp_file.write(code) + temp_file.close() + result = blender_run("--background", "--python", path) + return result + finally: + os.unlink(path) + + +def blender_eval_file(filepath): + """Executes the Python code located at `filepath` in a background + Blender subprocess created with `blender_run` and returns the + subprocess. + + """ + path = str(filepath) + process = blender_run( + "--factory-startup", "-noaudio", "--python", path, background=True + ) + return process + + +def blender_lpy_eval(code): + """Executes the Basilisp `code` in a Blender subprocess + created with `blender_eval` and returns its result.""" + # force rep to be with single quotes + code = repr(';;"\n' + code) + py_code = f"""from basilisp_blender import eval as evl +res = evl.eval_str({code}) +print(f":lpy-result {{res}}") +""" + return blender_eval(py_code) + + +def test_blender_exec_path_get(): + assert blender_exec_path_get() is not None + + +def test_blender_run(): + result = blender_run("--version", background=False) + assert result.stdout.startswith("Blender") + + +def test_blender_run_background(): + process = blender_run("--version", background=True) + stdout, stderr = process.communicate(timeout=2) + + assert stdout.startswith("Blender") + + +def test_blender_eval(): + result = blender_eval('print(":result hi")') + assert ":result hi" in result.stdout + + +@pytest.mark.skipif( + os.getenv("RUNNER_OS", "Linux") != "Linux", + reason="GHA UI test is only supported on Linux.", +) +def test_blender_eval_file(tmp_path): + codepath = tmp_path / "blender-eval-file-test" + sigfile = tmp_path / "blender-eval-file-test.signal" + with open(codepath, "w") as file: + file.write( + f"""import sys +print(":running...") +sys.stdout.flush() +with open({repr(str(sigfile))}, "w") as file: + file.write(":done") + """ + ) + + process = None + try: + process = blender_eval_file(codepath) + + file_exists_wait(sigfile, 10, 0.5) + assert os.path.exists(str(sigfile)) + + assert process.poll() is None + process.terminate() + out, error = process.communicate() + assert error == "" + assert out.startswith(":running...") + finally: + process.terminate() + + +def test_blender_lpy_eval(): + result = blender_lpy_eval("(+ 1024 1024)") + assert ":lpy-result 2048" in result.stdout diff --git a/tests/basilisp_blender/nrepl_server_test.lpy b/tests/basilisp_blender/nrepl_server_test.lpy new file mode 100644 index 0000000..9c2bfdb --- /dev/null +++ b/tests/basilisp_blender/nrepl_server_test.lpy @@ -0,0 +1,729 @@ +;; adapted from +;; https://github.com/basilisp-lang/basilisp/blob/b4d9c2d6ed1aaa9ba2f4b1dc0e8073813aab1315/tests/basilisp/contrib/nrepl_server_test.lpy +(ns tests.basilisp-blender.nrepl-server-test + (:require + [basilisp-blender.nrepl-server :as nr] + [basilisp.contrib.bencode :as bc] + [basilisp.io :as bio] + [basilisp.set :as set] + [basilisp.string :as str :refer [starts-with?]] + [basilisp.test :refer [deftest are is testing]]) + (:import + os + socket + tempfile + threading + time)) + +(def ^:dynamic *nrepl-port* + "The port the :lpy:py:`with-server` is bound to." + nil) + +(defmacro with-server + [opts & body] + "Create an nREPL server on a thread with + :lpy:fn:`basilisp.nrepl-server/server-make` passing in ``opts``, bind + its port to `*nrepl-port*`, run ``body`` on the main thread, and + then shutdown server." + `(let [srv# (nr/server-make ~opts)] + (doto (threading/Thread + ~'** + :target #(.serve-forever srv#) + :daemon true) + (.start)) + (try + (binding [*nrepl-port* (second (.-server-address srv#))] + ~@body) + (finally + (nr/server-shutdown! srv#))))) + +(defmacro with-connect [client & body] + "Open up a connection to the nREPL-server at ``*nrepl-port*`` and + run ``body``, with the ``client`` exposed as an anaphoric binding. + + ``client`` is a map with the following keys: + + :backlog* A helper atom for the :lpy:fn:`client-rev!` to keep track + of the arriving responses and any yet incomplete bencoded messages. + + :sock The socket connection to the server." + `(with [sock# (socket/socket socket/AF_INET socket/SOCK_STREAM)] + (let [~client {:sock sock# :backlog* (atom {:items [] :fraction nil})}] + (.connect sock# (python/tuple ["127.0.0.1" *nrepl-port*])) + ;; the high time out value is for accommodating the slow + ;; execution on pypy. + (.settimeout sock# 20) + ~@body))) + +(defn client-send! + "Send ``value`` to the server the ``client`` is connected to." + [client value] + (let [{:keys [sock]} client + v (bc/encode value)] + (.sendall sock v))) + +(defn client-recv! + "Receive and return nREPL response from the server the ``client`` is + connected to." + [client] + (let [{:keys [sock backlog*]} client] + (loop [{:keys [items fraction]} @backlog*] + (if-let [item (first items)] + (do (reset! backlog* {:items (drop 1 items) :fraction fraction}) + item) + + (let [data (.recv sock 8192) + data (if fraction (+ fraction data) data) + [items-d remaining :as response] (bc/decode-all data {:keywordize-keys true + :string-fn #(.decode % "utf-8")}) + items (concat items items-d) + [item & items-left] items] + (recur {:items items :fraction remaining})))))) + +(deftest nrepl-basic + (testing "basic" + (with-server {} + (with [sock (socket/socket socket/AF_INET socket/SOCK_STREAM)] + (do + (.connect sock #py ("127.0.0.1" *nrepl-port*)) + (let [encoded (bc/encode {:id 1 :op "clone"})] + (.sendall sock encoded) + (let [data (.recv sock 1024) + [{:keys [id new-session status] :as msg} _] (bc/decode data {:keywordize-keys true + :string-fn #(.decode % "utf-8")})] + (is (= id 1)) + (is (uuid-like? new-session)) + (is (= status ["done"])) + (.sendall sock (bc/encode {:id 2 :op "close"})) + (is (= [{:id 2 :status ["done"]} nil] + (-> (.recv sock 1024) + (bc/decode {:keywordize-keys true + :string-fn #(.decode % "utf-8")})))))))))) + + (testing "describe" + (with-server {} + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + (client-send! client{:id 2 :op "describe"}) + (let [{:keys [ops versions status]} (client-recv! client)] + (is (= ["done"] status)) + (is (= {:clone {} :close {} :complete {} :describe {} :eldoc {} :eval {} :info {} :load-file {}} ops)) + (let [{:keys [basilisp python]} versions] + (is (contains? basilisp :version-string)) + (is (contains? python :version-string))))))) + + (testing "unsupported" + (with-server {} + (with-connect client + (client-send! client {:id 1 :op "test-nrepl-server-unsupported"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["error" "unknown-op" "done"] status))))))) + +(deftest nrepl-server-symbol-identify + (are [result symbol-ns symbol-str] (= result (@#'nr/symbol-identify symbol-ns symbol-str)) + [:keyword (keyword (str *ns*) "x")] *ns* "::x" + [:keyword (keyword "basilisp.test" "xyz")] (the-ns 'basilisp.test) "::xyz" + [:keyword (keyword "basilisp-blender.nrepl-server" "x")] *ns* "::nr/x" + + [:keyword (keyword "x")] *ns* ":x" + [:keyword (keyword "xyz.abc" "x")] *ns* ":xyz.abc/x" + [:keyword (keyword "nr" "x")] *ns* ":nr/x" + + ;; ns + [:special-form 'if] *ns* "if" ;; special + [:var #'basilisp.string/starts-with?] *ns* "starts-with?" ;; refer + [:var #'basilisp.string/starts-with?] *ns* "str/starts-with?" ;; refer + [:var #'basilisp.string/starts-with?] *ns* "basilisp.string/starts-with?" ;; refer + [:var (ns-resolve *ns* 'client-send!)] *ns* "client-send!" ;; this ns fn + [:var #'basilisp.test/is] *ns* "is" ;; test ns refer macro + [:var #'basilisp.test/*test-section*] (the-ns 'basilisp.test) "*test-section*" + + ;; other + [:other 'python/tuple] *ns* "python/tuple" + [:other 'xyz] *ns* "xyz" + [:other 'xyz] (the-ns 'basilisp.test) "xyz" + + ;; unspecified behaviour that can lead to errors + [:error "AttributeError(\"'PersistentList' object has no attribute 'ns'\")"] *ns* "'abc" ;; passing in a symbol + )) + +(deftest nrepl-server-complete + (testing "basic" + ;; randomly interchange :prefix (cider) with :symbol (calva) below + (with-server {} + (with-connect client + (let [id* (atom 0) + id-inc! #(swap! id* inc)] + (client-send! client {:id (id-inc!) :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + ;; basic lookup + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "apply"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "apply" :type "function" :ns "basilisp.core"} + {:candidate "apply-kw" :type "function" :ns "basilisp.core"} + {:candidate "apply-method" :type "macro" :ns "basilisp.core"}]} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :symbol "clojure.string/blank?"}) + (is (= {:id @id* :status ["done"] + :completions []} + (client-recv! client))) + + ;; current ns + (client-send! client {:id (id-inc!) :op "eval" :code "(def abc 1) (defn efg [] 2) (defmacro hij [] '(3)) 9"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "9"} + {:id @id* :ns "user" :status ["done"]}) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "ab"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "abc" :ns "user" :type "var"} + {:candidate "abs" :ns "basilisp.core" :type "function"}]} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "ef"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "efg" :ns "user" :type "function"}]} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "hi"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "hij" :ns "user" :type "macro"}]} + (client-recv! client))) + + ;; create and reference another namespace + (client-send! client {:id (id-inc!) :op "eval" :code "(ns test.nrepl.ns) (def testme 1) testme"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "test.nrepl.ns" :value "1"} + {:id @id* :ns "test.nrepl.ns" :status ["done"]}) + (client-send! client {:id (id-inc!) :op "complete" :prefix "testme"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "testme" :ns "test.nrepl.ns" :type "var"}]} + (client-recv! client))) + ;; names from the user interface are not available from here + (client-send! client {:id (id-inc!) :op "complete" :prefix "ab"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "abs" :ns "basilisp.core" :type "function"}]} + (client-recv! client))) + ;; but they are available if we specify the ns + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "ab"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "abc" :ns "user" :type "var"} + {:candidate "abs" :ns "basilisp.core" :type "function"}]} + (client-recv! client))) + ;; got back to user + (client-send! client {:id (id-inc!) :op "eval" :code "(in-ns 'user) 5"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "5"} + {:id @id* :ns "user" :status ["done"]}) + ;; look for completio in the test namespace + (client-send! client {:id (id-inc!) :op "complete" :ns "test.nrepl.ns" :prefix "testme"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "testme" :ns "test.nrepl.ns" :type "var"}]} + (client-recv! client))) + ;; that completion to the test namespace is not available without a ns + (client-send! client {:id (id-inc!) :op "complete" :prefix "testme"}) + (is (= {:id @id* :status ["done"] + :completions []} + (client-recv! client))) + + ;; aliased ns and refers + ;; + ;; first test that fqn and aliased completion are not available yet + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "clojure.string/blank?"}) + (is (= {:id @id* :status ["done"] :completions []} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :symbol "str/blank?"}) + (is (= {:id @id* :status ["done"] :completions []} + (client-recv! client))) + ;; require string ns + (client-send! client {:id (id-inc!) :op "eval" :code "(require '[clojure.string :as str :refer [join]])"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "nil"} + {:id @id* :ns "user" :status ["done"]}) + ;; test fqn, aliased and refer comletions to the string ns + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "clojure.string/blank?"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "clojure.string/blank?" :ns "basilisp.string" :type "function"}]} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :symbol "str/bl"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "str/blank?" :ns "basilisp.string" :type "function"}]} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :symbol "joi"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "join" :ns "basilisp.string" :type "function"}]} + (client-recv! client))) + + ;; ns completions + (client-send! client {:id (id-inc!) :op "complete" :ns "user" :prefix "clojur"}) + (is (= {:id @id* :status ["done"] + :completions [{:candidate "clojure.core/"} + {:candidate "clojure.string/"}]} + (client-recv! client))) + + ;; ns full names completions + (client-send! client {:id (id-inc!) :op "eval" :code "(require '[clojure.test :as test]) 1"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "1"} + {:id @id* :ns "user" :status ["done"]}) + (client-send! client {:id (id-inc!) :op "complete" :prefix "clojure.test/"}) + (is (= {:id @id* :status ["done"] + :completions [{:type "var" :ns "basilisp.test" :candidate "clojure.test/*test-failures*"} + {:type "var" :ns "basilisp.test" :candidate "clojure.test/*test-name*"} + {:type "var" :ns "basilisp.test" :candidate "clojure.test/*test-section*"} + {:type "macro" :ns "basilisp.test" :candidate "clojure.test/are"} + {:type "macro" :ns "basilisp.test" :candidate "clojure.test/deftest"} + {:type "var" :ns "basilisp.test" :candidate "clojure.test/gen-assert"} + {:type "macro" :ns "basilisp.test" :candidate "clojure.test/is"} + {:type "macro" :ns "basilisp.test" :candidate "clojure.test/testing"} + {:type "var" :ns "basilisp.test" :candidate "clojure.test/use-fixtures"}]} + (client-recv! client))) + (client-send! client {:id (id-inc!) :op "complete" :prefix "test/"}) + (is (= {:id @id* :status ["done"] + :completions [{:type "var" :ns "basilisp.test" :candidate "test/*test-failures*"} + {:type "var" :ns "basilisp.test" :candidate "test/*test-name*"} + {:type "var" :ns "basilisp.test" :candidate "test/*test-section*"} + {:type "macro" :ns "basilisp.test" :candidate "test/are"} + {:type "macro" :ns "basilisp.test" :candidate "test/deftest"} + {:type "var" :ns "basilisp.test" :candidate "test/gen-assert"} + {:type "macro" :ns "basilisp.test" :candidate "test/is"} + {:type "macro" :ns "basilisp.test" :candidate "test/testing"} + {:type "var" :ns "basilisp.test" :candidate "test/use-fixtures"}]} + (client-recv! client)))))))) + +(deftest nrepl-server-eval + (testing "basic" + (with-server {} + (with-connect client + (let [id* (atom 0) + id-inc! #(swap! id* inc)] + (client-send! client {:id (id-inc!) :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + (client-send! client {:id (id-inc!) :op "eval" :code "(+ 1 3)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "4"} + {:id @id* :ns "user" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(println :hi \"there\")"}) + (are [response] (= response (client-recv! client)) + {:id @id* :out ":hi"} + {:id @id* :out " "} + {:id @id* :out "there"} + {:id @id* :out os/linesep} + {:id @id* :ns "user" :value "nil"} + {:id @id* :ns "user" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :ns "xyz" :code "(ns xyz (:import [sys :as s])) (println s/__name__) (* 2 3)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :out "sys"} + {:id @id* :out os/linesep} + {:id @id* :ns "xyz" :value "6"} + {:id @id* :ns "xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(/ 3 0)"}) + (is (= {:id @id* :err "ZeroDivisionError('Fraction(3, 0)')"} (client-recv! client))) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "xyz" ns)) + (is (= ["eval-error"] status)) + (is (str/starts-with? ex "Traceback (most recent call last):"))) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(println :hey)\n(/ 4 0)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :out ":hey"} + {:id @id* :out os/linesep} + {:id @id* :err "ZeroDivisionError('Fraction(4, 0)')"}) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "xyz" ns)) + (is (= ["eval-error"] status)) + (is (not= -1 (.find ex "File \"\", line 2")) ex)) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "[*1 *2 *3 *e]"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :value "[6 nil 4 ZeroDivisionError('Fraction(4, 0)')]"} + {:id @id* :ns "xyz" :status ["done"]}) + + ;; error with :file + (client-send! client {:id (id-inc!) :op "eval" :file "/hey/you.lpy" :code "1\n2\n(/ 5 0)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :err "ZeroDivisionError('Fraction(5, 0)')"}) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "xyz" ns)) + (is (= ["eval-error"] status)) + (is (not= -1 (.find ex "File \"/hey/you.lpy\", line 3")) ex)) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :status ["done"]}) + + ;; error conditions + (client-send! client {:id (id-inc!) :op "eval" :code "(xyz"}) + (let [{:keys [id err]} (client-recv! client)] + (is (= @id* id)) + (is (str/starts-with? err "basilisp.lang.reader.SyntaxError"))) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "xyz" ns)) + (is (= ["eval-error"] status)) + (is (str/starts-with? ex "Traceback (most recent call last):"))) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(+ 3 5)" :ns "not-there"}) + (is (= {:id @id* :err "CompilerException(msg=\"unable to resolve symbol '+' in this context\", phase=, filename='', form=+, lisp_ast=None, py_ast=None)"} (client-recv! client))) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "not-there" ns)) + (is (= ["eval-error"] status)) + (is (str/starts-with? ex "Traceback (most recent call last):"))) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "not-there" :status ["done"]}))))) + + (testing "malformed" + (with-server {} + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + ;; no code + (client-send! client {:id 2 :op "eval"}) + (are [response] (= response (client-recv! client)) + {:id 2 :ns "user" :value "nil"} + {:id 2 :ns "user" :status ["done"]}) + + ;; bad namespace + (client-send! client {:id 3 :op "eval" :code "(+ 3 5)" :ns "#,,"}) + (is (= {:id 3 :err "CompilerException(msg=\"unable to resolve symbol '+' in this context\", phase=, filename='', form=+, lisp_ast=None, py_ast=None)"} (client-recv! client))) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= 3 id)) + (is (= "#,," ns)) + (is (= ["eval-error"] status)) + (is (str/starts-with? ex "Traceback (most recent call last):"))) + (are [response] (= response (client-recv! client)) + {:id 3 :ns "#,," :status ["done"]}))))) + +(deftest nrepl-server-info + (testing "nrepl server info" + (with-server {} + (with-connect client + ;; cover both :sym (cider) with :symbol (calva) instances below. + + (let [id* (atom 0) + id-inc! #(swap! id* inc)] + (client-send! client {:id (id-inc!) :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + (client-send! client {:id (id-inc!) :op "info" :ns "user" :sym "sort-by"}) + (let [{:keys [file line] :as response} (client-recv! client) + {:keys [doc] + meta-file :file} (meta (resolve 'sort-by))] + (is (= {:ns "basilisp.core" :status ["done"] :id @id* :arglists-str "[keyfn coll]\n[keyfn cmp coll]" + :doc doc :name "sort-by"} + (select-keys response [:ns :status :id :arglists-str :doc :name]))) + (is (= meta-file file))) + + ;; test fqdn, aliases and refers + (client-send! client {:id (id-inc!) :op "eval" + :code "(in-ns 'xyz) (ns xyz (:require [clojure.set :as set :refer [join]])) 6"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :value "6"} + {:id @id* :ns "xyz" :status ["done"]}) + ;; fqdn + (client-send! client {:id (id-inc!) :op "info" :ns "xyz" :symbol "clojure.set/difference"}) + (let [response (client-recv! client)] + (is (= {:doc (:doc (meta (resolve 'set/difference))) + :name "difference" :status ["done"] :id @id*} + (select-keys response [:doc :status :id :name])))) + ;; alias + (client-send! client {:id (id-inc!) :op "info" :ns "xyz" :symbol "set/union"}) + (let [response (client-recv! client)] + (is (= {:doc (:doc (meta (resolve 'set/union))) + :name "union" :status ["done"] :id @id*} + (select-keys response [:doc :status :id :name])))) + ;; refer + (client-send! client {:id (id-inc!) :op "info" :ns "xyz" :symbol "join"}) + (let [response (client-recv! client)] + (is (= {:doc (:doc (meta (resolve 'set/join))) + :name "join" :status ["done"] :id @id*} + (select-keys response [:doc :status :id :name])))) + + (client-send! client {:id (id-inc!) :op "info" :ns "user" :symbol "abcde"}) + (let [response (client-recv! client)] + (is (= {:status ["done"] :id @id*} + (select-keys response [:doc :status :id :name])))) + + (client-send! client {:id (id-inc!) :op "info"}) + (is (= {:id @id* :status ["done"]} + (client-recv! client))))))) + + (testing "nrepl server eldoc" + (with-server {} + (with-connect client + (let [id* (atom 0) + id-inc! #(swap! id* inc)] + (client-send! client {:id (id-inc!) :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + (client-send! client {:id (id-inc!) :op "eldoc" :ns "user" :sym "sort-by"}) + (let [{:keys [file line] :as response} (client-recv! client)] + (is (= {:ns "basilisp.core" :status ["done"] :id @id* :type "function" + :docstring (:doc (meta (resolve 'sort-by))) :name "sort-by" + :eldoc [["keyfn" "coll"] ["keyfn" "cmp" "coll"]]} + response))) + + (client-send! client {:id (id-inc!) :op "eldoc" :sym "doesnot-exists"}) + (is (= {:id @id* :status ["done" "no-eldoc"]} + (client-recv! client))) + + (client-send! client {:id (id-inc!) :op "eldoc"}) + (is (= {:id @id* :status ["done" "no-eldoc"]} + (client-recv! client)))))))) + +(deftest nrepl-server-load-file + (testing "basic" + (with-server {} + (with-connect client + (let [id* (atom 0) + id-inc! #(swap! id* inc)] + (client-send! client {:id (id-inc!) :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + (client-send! client {:id (id-inc!) :op "load-file" + :ns "user" :file "(ns abc.xyz (:require [clojure.string :as str]) (:import [sys :as s])) (defn afn [] (str/lower-case \"ABC\")) (afn)" + :file-name "xyz.lpy" :file-path "/abc/xyz.lpy"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.xyz" :value "\"abc\""} + {:id @id* :ns "abc.xyz" :status ["done"]}) + + + (client-send! client {:id (id-inc!) :op "eval" :ns "abc.xyz" :code "s/__name__"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.xyz" :value "\"sys\""} + {:id @id* :ns "abc.xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(in-ns 'abc.other)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.other" :value "abc.other"} + {:id @id* :ns "abc.other" :status ["done"]}) + (client-send! client {:id (id-inc!) :op "load-file" + :ns "user" :file "(ns abc.other) (defn afn [] 55) (+ 5 4)" + :file-name "other.lpy" :file-path "/abc/other.lpy"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.other" :value "9"} + {:id @id* :ns "abc.other" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(afn)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.other" :value "55"} + {:id @id* :ns "abc.other" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "(abc.xyz/afn)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.other" :value "\"abc\""} + {:id @id* :ns "abc.other" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "load-file" :ns "user" :file "(ns abc.third)\n\n(/ 3 0)" + :file-name "third.lpy" :file-path "/abc/third.lpy"}) + (is (= {:id @id* :err "ZeroDivisionError('Fraction(3, 0)')"} (client-recv! client))) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "abc.third" ns)) + (is (= ["eval-error"] status)) + (is (not= -1 (.find ex "File \"/abc/third.lpy\", line 3")) ex) + (is (str/starts-with? ex "Traceback (most recent call last):"))) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.third" :status ["done"]})))) + + (testing "no file" + (with-server {} + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + (client-send! client {:id 2 :op "load-file" :ns "user"}) + (are [response] (= response (client-recv! client)) + {:id 2 :ns "user" :value "nil"} + {:id 2 :ns "user" :status ["done"]})))))) + +(deftest nrepl-server-params + (testing "buffer size" + (with-server {:recv-buffer-size 5} + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + (doseq [i (range 2 100)] + (client-send! client {:id i :op "info" :ns "user" :sym "sort-by"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))))))) + + (testing "nrepl server port" + (is (thrown? python/OverflowError + ;; just test any port number can be passed in, -1 + ;; will throw an exception. + (with-server {:port -1} + (throw python/ZeroDivisionError))))) + + (testing "nrepl-server port file and address" + (let [server* (atom nil) + [fd filename] (tempfile/mkstemp "nrepl-server-port-test")] + (doto (threading/Thread + ** + :target #(nr/start-server! {:server* server* :nrepl-port-file filename :host "0.0.0.0"}) + :daemon true) + (.start)) + (try + (time/sleep 1) ;; give some time to the server to settle down + (is @server*) + (is (bio/exists? filename)) + (let [port-filename (slurp filename) + {:keys [server]} @server* + [host port] (py->lisp (.-server-address server))] + (is (= host "0.0.0.0")) + (is (= (str port) port-filename))) + + (finally + (let [{:keys [shutdown-fn]} @server*] + (shutdown-fn)) + (os/close fd) + (os/unlink filename)))))) + +(defn- work-do-thread + [work-fn, work-count*, stop-sig*, sleep-sec, iter-count-max] + "Executes `work-fn` repeatedly in a loop within a separate thread +pausing for `sleep-sec` between execution. The `work-count*` atom is +incremented by the number of nREPL client requests executed by the +`work-fn`. + +The loop will terminate and the thread will exit when either the +`stop-sig*` atom is set to a non-nil value, or `iter-count-max` +iterations are reached. + +Returns a future that will return `:done` on completion." + (future + (try + (loop [cnt 0] + (work-fn (fn [_ _] (swap! work-count* inc))) + (when (and (not @stop-sig*) (< cnt iter-count-max)) + (time/sleep sleep-sec) + (recur (inc cnt))) + ) + (catch Exception e + (println :work-do-thread-error e))) + :done)) + +(deftest nrepl-server-async + (testing "async work" + (let [work* (atom {}) + stop-sig* (atom false) + work-count* (atom 0) + + id* (atom 0) + id-inc! #(swap! id* inc)] + (with-server {:work* work*} + + (let [work-thread (work-do-thread (partial nr/clients-work-do! work*) + work-count* stop-sig*, 0.5, 5)] + + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + (client-send! client {:id (id-inc!) :op "eval" :code "(+ 1 3)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "4"} + {:id @id* :ns "user" :status ["done"]})) + + ;; stop thread and wait to finish + (reset! stop-sig* true) + (is (= :done @work-thread)) + + (is (= 2 @work-count*)))))) + + + (testing "async server" + (let [start-event (threading/Event) + server* (atom {:start-event start-event}) + stop-sig* (atom false) + work-count* (atom 0) + + server-thread (threading/Thread + ** + :target #(try + (nr/start-server! {:server* server* :async? true}) + (catch Exception e + (println :nrepl-server-async-error e))) + :daemon true) + + + + id* (atom 0) + id-inc! #(swap! id* inc)] + (.start server-thread) + (is (.wait start-event 1)) + (try + (let [{:keys [server work-fn]} @server* + work-thread (work-do-thread work-fn work-count* stop-sig*, 0.5, 5)] + + (binding [*nrepl-port* (second (.-server-address server))] + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + (client-send! client {:id (id-inc!) :op "eval" :code "(+ 1 3)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "4"} + {:id @id* :ns "user" :status ["done"]}))) + + ;; stop thread and wait to finish + (reset! stop-sig* true) + (is (= :done @work-thread)) + (is (= 2 @work-count*))) + (finally + (let [{:keys [shutdown-fn]} @server*] + (shutdown-fn)))))) + + (testing "async server thread" + (let [{:keys [server server-thread work-fn shutdown-fn] :as ret_} (nr/server-thread-async-start! {}) + + stop-sig* (atom false) + work-count* (atom 0) + + id* (atom 0) + id-inc! #(swap! id* inc)] + (is (and server server-thread work-fn shutdown-fn)) + (try + (let [work-thread (work-do-thread work-fn work-count* stop-sig*, 0.5, 5)] + (binding [*nrepl-port* (second (.-server-address server))] + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + + (client-send! client {:id (id-inc!) :op "eval" :code "(+ 1 3)"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "user" :value "4"} + {:id @id* :ns "user" :status ["done"]}))) + + ;; stop work thread and wait to finish + (reset! stop-sig* true) + (is (= :done @work-thread)) + (is (= 2 @work-count*))) + + ;; stop server thread + (shutdown-fn) + (.join server-thread ** :timeout 1) + + (finally + (shutdown-fn)))))) diff --git a/tests/basilisp_blender/nrepl_test.py b/tests/basilisp_blender/nrepl_test.py new file mode 100644 index 0000000..fd5a493 --- /dev/null +++ b/tests/basilisp_blender/nrepl_test.py @@ -0,0 +1,82 @@ +import threading +import time + +import nrepl as nrepl_client + +from basilisp_blender.nrepl import server_thread_async_start + + +def work_thread_do(workfn, interval_sec=0.1): + """Creates and starts a daemon thread that calls the `workfn` + repeatedly in a loop pausing for `internal_secs` seconds (defaults + to 0.1) between executions. + + Returns the thread object and a `threading.Event`. When this event + is set, the loop will terminate and the thread will exit. + + """ + stop_event = threading.Event() + + def work_do(): + try: + while not stop_event.wait(interval_sec): + workfn() + time.sleep(interval_sec) + except e: + print(f":work-thread-error {e}") + + thread = threading.Thread(target=work_do, daemon=True) + thread.start() + return thread, stop_event + + +def test_server_thread_async_start(tmpdir): + portfile = tmpdir / ".basilisp-blender-test-port" + shutdownfn = None + work_thread = None + work_stop_event = None + try: + workfn, shutdownfn = server_thread_async_start( + nrepl_port_filepath=str(portfile) + ) + + assert workfn and shutdownfn, ":server-error :could-not-start" + + port = None + with open(portfile, "r") as file: + content = file.read().strip() + port = int(content) + + assert isinstance(port, int) and port > 0 + + work_thread, work_stop_event = work_thread_do(workfn) + + def nrepl_client_test(): + client = None + try: + client = nrepl_client.connect(f"nrepl://localhost:{port}") + client.write({"id": 1, "op": "clone"}) + result = client.read() + assert "status" in result and result["status"] == ["done"] + client.write({"id": 2, "op": "eval", "code": "(reduce + (range 20))"}) + result = client.read() + assert "value" in result and result["value"] == "190" + except Exception as e: + assert e is None + finally: + if client: + client.close() + + client_thread = threading.Thread(target=nrepl_client_test, daemon=True) + client_thread.start() + client_thread.join(timeout=5) + assert not client_thread.is_alive() + + work_stop_event.set() + work_thread.join(timeout=5) + assert not work_thread.is_alive() + finally: + shutdownfn() + + if work_stop_event: + work_stop_event.set() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c6481d5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["pytester"]