From b13c6b061814c63c74cb15877493d53cb1c5bcfe Mon Sep 17 00:00:00 2001 From: ikappaki Date: Sat, 3 Aug 2024 10:59:58 +0100 Subject: [PATCH] doc strings, readme update --- README.md | 57 +++++++++++++++---- examples/torus_pattern.lpy | 10 ++-- pyproject.toml | 2 +- scripts/bb_package_install.py | 17 +++--- scripts/blender_install.py | 8 +++ src/basilisp_blender/__init__.py | 9 ++- src/basilisp_blender/nrepl.py | 45 ++++++++------- src/basilisp_blender/nrepl_server.lpy | 23 ++++++-- src/dev/dev_utils.py | 25 ++++++++ .../integration/int_nrepl_test.py | 10 ++-- .../integration/test_utils.py | 47 +++++++++------ tests/basilisp_blender/nrepl_server_test.lpy | 18 ++++-- tests/basilisp_blender/nrepl_test.py | 30 +++++++--- 13 files changed, 212 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 66570bf..6aa271c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -[![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 +[![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](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). +# Basilisp Blender Integration + ## Overview -`basilisp-blender` is a Python library designed to facilitate the execution of Basilisp code within Blender 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. +`basilisp-blender` is a Python library designed to facilitate the execution of Basilisp Clojure code within Blender 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. ## Features * Evaluate Basilisp Code: Execute Basilisp code snippets directly from code strings, files or Blender’s text editor. @@ -59,13 +60,18 @@ from basilisp_blender.nrepl import server_start shtudown_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 `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. -For a more convinient setup, you can specify the path to a `.nrepl-port` file in your Basilisp's project's root directory. This allows some Clojure editors (such as CIDER or Calva) to automatically detect the port and connect to 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 or Calva) to automatically detect the port when `connect`'ing to the server: ```python +from basilisp_blender.nrepl import server_start + shtudown_fn = server_start(nrepl_port_filepath="/.nrepl-port") ``` @@ -73,6 +79,8 @@ 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 @@ -81,9 +89,9 @@ Here is an example of Basilisp code to create a torus pattern using the bpy Blen (:import bpy math)) -(def object (-> bpy/ops .-object)) -(def materials (-> bpy/data .-materials)) -(def mesh (-> bpy/ops .-mesh)) +(def object (.. bpy/ops -object)) +(def materials (.. bpy/data -materials)) +(def mesh (.. bpy/ops -mesh)) (defn clear-mesh-objects [] @@ -96,7 +104,7 @@ Here is an example of Basilisp code to create a torus pattern using the bpy Blen (defn create-random-material [] (let [mat (.new materials ** :name "RandomMaterial") _ (set! (.-use-nodes mat) true) - bsdf (aget (-> mat .-node-tree .-nodes) "Principled BSDF")] + bsdf (aget (.. mat -node-tree -nodes) "Principled BSDF")] (set! (-> bsdf .-inputs (aget "Base Color") .-default-value) [(rand) (rand) (rand) 1]) @@ -109,7 +117,7 @@ Here is an example of Basilisp code to create a torus pattern using the bpy Blen :location location :major-segments segments :minor-segments segments) - (let [obj (-> bpy/context .-object) + (let [obj (.. bpy/context -object) material (create-random-material)] (-> obj .-data .-materials (.append material)))) @@ -135,8 +143,35 @@ Here is an example of Basilisp code to create a torus pattern using the bpy Blen ![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 + +shtudown_fn = server_start(port=8889, interval_sec=0.05) +``` + # Development +This package uses the [Poetry tool]( bpy/ops .-object)) -(def materials (-> bpy/data .-materials)) -(def mesh (-> bpy/ops .-mesh)) +(def object (.. bpy/ops -object)) +(def materials (.. bpy/data -materials)) +(def mesh (.. bpy/ops -mesh)) (defn clear-mesh-objects [] @@ -18,7 +18,7 @@ (defn create-random-material [] (let [mat (.new materials ** :name "RandomMaterial") _ (set! (.-use-nodes mat) true) - bsdf (aget (-> mat .-node-tree .-nodes) "Principled BSDF")] + bsdf (aget (.. mat -node-tree -nodes) "Principled BSDF")] (set! (-> bsdf .-inputs (aget "Base Color") .-default-value) [(rand) (rand) (rand) 1]) @@ -31,7 +31,7 @@ :location location :major-segments segments :minor-segments segments) - (let [obj (-> bpy/context .-object) + (let [obj (.. bpy/context -object) material (create-random-material)] (-> obj .-data .-materials (.append material)))) diff --git a/pyproject.toml b/pyproject.toml index 68983e1..07d6d0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "basilisp-blender" -version = "0.0.0b29" +version = "0.0.0b30" description = "" authors = ["ikappaki"] readme = "README.md" diff --git a/scripts/bb_package_install.py b/scripts/bb_package_install.py index 50dfc90..924370e 100644 --- a/scripts/bb_package_install.py +++ b/scripts/bb_package_install.py @@ -1,18 +1,17 @@ +"""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 - - -def file_exists_wait(filepath, count, interval_ms): - while count > 0: - if os.path.exists(str(filepath)) and os.path.getsize(filepath) > 0: - break - count -= 1 - time.sleep(interval_ms) +from dev.dev_utils import blender_exec_path_get, file_exists_wait blender_path = blender_exec_path_get() diff --git a/scripts/blender_install.py b/scripts/blender_install.py index 4bb10ef..05eff8e 100644 --- a/scripts/blender_install.py +++ b/scripts/blender_install.py @@ -1,3 +1,11 @@ +"""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 diff --git a/src/basilisp_blender/__init__.py b/src/basilisp_blender/__init__.py index 11fbb4b..80f389b 100644 --- a/src/basilisp_blender/__init__.py +++ b/src/basilisp_blender/__init__.py @@ -13,10 +13,17 @@ 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, "d:/removeme/bas/basilisp-blender.log") +# log_level_set(logging.DEBUG, "basilisp-blender.log") diff --git a/src/basilisp_blender/nrepl.py b/src/basilisp_blender/nrepl.py index 630fe72..11c50ae 100644 --- a/src/basilisp_blender/nrepl.py +++ b/src/basilisp_blender/nrepl.py @@ -1,10 +1,12 @@ """Functions that depend on the `bpy` module.""" import atexit +import importlib import sys -from pathlib import Path -from basilisp_blender.eval import eval_str +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): @@ -24,16 +26,20 @@ def server_thread_async_start(host="127.0.0.1", port=0, nrepl_port_filepath=None """ assert '"' not in host assert port >= 0 - if nrepl_port_filepath is not None: - nrepl_port_filepath = Path(nrepl_port_filepath).as_posix() - - work_fn, shutdown_fn = eval_str( - f"""(require '[basilisp-blender.nrepl-server :as nr]) - (let [{{:keys [work-fn shutdown-fn]}} - (nr/server-thread-async-start! {{:host "{host}" :port {port} :nrepl-port-file "{nrepl_port_filepath}"}})] - [work-fn shutdown-fn]) -""" + + 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, + } + ) ) + + work_fn = ret[kw.keyword("work-fn")] + shutdown_fn = ret[kw.keyword("shutdown-fn")] assert work_fn and shutdown_fn, ":server-error :could-not-be-started" return work_fn, shutdown_fn @@ -45,16 +51,13 @@ def server_thread_async_start(host="127.0.0.1", port=0, nrepl_port_filepath=None 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`. Client requests are queued and executed at - `interval_sec` (defaults to 0.1) by a `bpy.app.timers` timer for - thread safety. The server is register to shutdown at program exit. - - 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. + """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 diff --git a/src/basilisp_blender/nrepl_server.lpy b/src/basilisp_blender/nrepl_server.lpy index 93d1029..cc3d6d2 100644 --- a/src/basilisp_blender/nrepl_server.lpy +++ b/src/basilisp_blender/nrepl_server.lpy @@ -515,8 +515,8 @@ :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 or if it fails to start. The - map will be populated with the following keys + server is about to enter its main loop. The map will be populated + with the following keys :server Set to the server reference. @@ -550,15 +550,30 @@ (when start-event (.set start-event)) (.serve-forever server) (catch python/KeyboardInterrupt _e - (when start-event (.set start-event)) (println "Exiting in response to a keyboard interrupt...")) (catch python/Exception e - (when start-event (.set start-event)) (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}) diff --git a/src/dev/dev_utils.py b/src/dev/dev_utils.py index 35afa4f..b8a2a41 100644 --- a/src/dev/dev_utils.py +++ b/src/dev/dev_utils.py @@ -1,3 +1,4 @@ +"Development utils shared amongst scripts and tests, but excluded from the package." import os import platform import shutil @@ -5,7 +6,26 @@ 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)) @@ -13,6 +33,11 @@ def blender_home_get(): 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") diff --git a/tests/basilisp_blender/integration/int_nrepl_test.py b/tests/basilisp_blender/integration/int_nrepl_test.py index 6d19a08..5e4e4b2 100644 --- a/tests/basilisp_blender/integration/int_nrepl_test.py +++ b/tests/basilisp_blender/integration/int_nrepl_test.py @@ -1,19 +1,17 @@ import os import threading -import time -import nrepl +import nrepl as nrepl_client import pytest -from tests.basilisp_blender.integration import test_utils as tu -from tests.basilisp_blender.nrepl_test import work_thread_do +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 test is only supported on Linux.", + reason="GHA UI testing is only supported on Linux.", ) def test_server_start(tmp_path): codefile = tmp_path / "server-start-code-file.py" @@ -52,7 +50,7 @@ def test_server_start(tmp_path): def nrepl_client_test(): client = None try: - client = nrepl.connect(f"nrepl://localhost:{port}") + 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"] diff --git a/tests/basilisp_blender/integration/test_utils.py b/tests/basilisp_blender/integration/test_utils.py index 2e3382e..3bd45af 100644 --- a/tests/basilisp_blender/integration/test_utils.py +++ b/tests/basilisp_blender/integration/test_utils.py @@ -1,29 +1,33 @@ +"Integration test utils." import os -import shutil import subprocess import tempfile import time import pytest -from dev.dev_utils import blender_exec_path_get +from dev.dev_utils import blender_exec_path_get, file_exists_wait pytestmark = pytest.mark.integration -def file_exists_wait(filepath, count, interval_ms): - while count > 0: - if os.path.exists(str(filepath)) and os.path.getsize(filepath) > 0: - break - count -= 1 - time.sleep(interval_ms) +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. -def blender_run(*args, **kwargs): + 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 - background = kwargs["background"] result = None if background: result = subprocess.Popen( @@ -39,30 +43,41 @@ def blender_run(*args, **kwargs): 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, background=False) + result = blender_run("--background", "--python", path) return result finally: os.unlink(path) -def blender_eval_file(codefile): - path = str(codefile) +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(lpy_code): +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 - lpy_code = repr(';;"\n' + lpy_code) + code = repr(';;"\n' + code) py_code = f"""from basilisp_blender import eval as evl -res = evl.eval_str({lpy_code}) +res = evl.eval_str({code}) print(f":lpy-result {{res}}") """ return blender_eval(py_code) diff --git a/tests/basilisp_blender/nrepl_server_test.lpy b/tests/basilisp_blender/nrepl_server_test.lpy index eb4fa59..06101a7 100644 --- a/tests/basilisp_blender/nrepl_server_test.lpy +++ b/tests/basilisp_blender/nrepl_server_test.lpy @@ -598,16 +598,22 @@ (os/unlink filename)))))) (defn- work-do-thread - [work-fn, work-count*, stop-sig*, sleep-sec, iter-max] - "Runs `work*` in a separate thread at intervals of `sleep-sec`, - stopping either when `stop-sig*` is received or `iter-max` - iterations are completed. Updates `work-count*` with the number of - completed tasks. Returns `:done` when the thread finishes." + [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-max)) + (when (and (not @stop-sig*) (< cnt iter-count-max)) (time/sleep sleep-sec) (recur (inc cnt))) ) diff --git a/tests/basilisp_blender/nrepl_test.py b/tests/basilisp_blender/nrepl_test.py index f7433e8..fd5a493 100644 --- a/tests/basilisp_blender/nrepl_test.py +++ b/tests/basilisp_blender/nrepl_test.py @@ -1,21 +1,27 @@ -import logging import threading import time -import nrepl +import nrepl as nrepl_client -from basilisp_blender import log_level_set -from basilisp_blender import nrepl as bbn +from basilisp_blender.nrepl import server_thread_async_start -def work_thread_do(workfn, interval_ms=0.1): +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_ms): + while not stop_event.wait(interval_sec): workfn() - time.sleep(interval_ms) + time.sleep(interval_sec) except e: print(f":work-thread-error {e}") @@ -26,10 +32,16 @@ def work_do(): def test_server_thread_async_start(tmpdir): portfile = tmpdir / ".basilisp-blender-test-port" - workfn, shutdownfn = bbn.server_thread_async_start(nrepl_port_filepath=portfile) + 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() @@ -42,7 +54,7 @@ def test_server_thread_async_start(tmpdir): def nrepl_client_test(): client = None try: - client = nrepl.connect(f"nrepl://localhost:{port}") + 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"]