Skip to content

Commit

Permalink
doc strings, readme update
Browse files Browse the repository at this point in the history
  • Loading branch information
ikappaki committed Aug 3, 2024
1 parent 71550ac commit b13c6b0
Show file tree
Hide file tree
Showing 13 changed files with 212 additions and 89 deletions.
57 changes: 46 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -59,20 +60,27 @@ 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="<project-root-path>/.nrepl-port")
```

Replace `<project-root-path>` 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
Expand All @@ -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 []
Expand All @@ -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])
Expand All @@ -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))))

Expand All @@ -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](<https://python-poetry.org/docs/) for managing development tasks.

## Testing

You can run tests using the following command:
Expand Down
10 changes: 5 additions & 5 deletions examples/torus_pattern.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
(: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 []
Expand All @@ -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])
Expand All @@ -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))))

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "basilisp-blender"
version = "0.0.0b29"
version = "0.0.0b30"
description = ""
authors = ["ikappaki"]
readme = "README.md"
Expand Down
17 changes: 8 additions & 9 deletions scripts/bb_package_install.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
8 changes: 8 additions & 0 deletions scripts/blender_install.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/basilisp_blender/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
45 changes: 24 additions & 21 deletions src/basilisp_blender/nrepl.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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
Expand Down
23 changes: 19 additions & 4 deletions src/basilisp_blender/nrepl_server.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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})
Expand Down
25 changes: 25 additions & 0 deletions src/dev/dev_utils.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
"Development utils shared amongst scripts and tests, but excluded from the package."
import os
import platform
import shutil

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")
Expand Down
10 changes: 4 additions & 6 deletions tests/basilisp_blender/integration/int_nrepl_test.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"]
Expand Down
Loading

0 comments on commit b13c6b0

Please sign in to comment.