From 40ca109c4397437ca0727fc9467c8a9cc977d416 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Fri, 9 Aug 2024 15:21:56 -0400 Subject: [PATCH 01/10] Start working on setting up test infrastructure. --- .github/workflows/ci-workflows.yml | 13 + codecov.yml | 10 + glue_ar/__init__.py | 23 +- glue_ar/common/__init__.py | 2 + glue_ar/common/tests/test_scatter_gltf.py | 5 + glue_ar/common/tests/test_shapes.py | 81 +- glue_ar/conftest.py | 3 + glue_ar/{common/tests => jupyter}/__init__.py | 0 glue_ar/qt/__init__.py | 0 glue_ar/qt/qr.py | 23 +- pyproject.toml | 24 - setup.cfg | 3 - setup.py | 708 +++++++++++++++++- setupbase.py | 698 ----------------- tox.ini | 2 + 15 files changed, 840 insertions(+), 755 deletions(-) create mode 100644 codecov.yml create mode 100644 glue_ar/common/tests/test_scatter_gltf.py create mode 100644 glue_ar/conftest.py rename glue_ar/{common/tests => jupyter}/__init__.py (100%) create mode 100644 glue_ar/qt/__init__.py delete mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setupbase.py diff --git a/.github/workflows/ci-workflows.yml b/.github/workflows/ci-workflows.yml index 13a2216..ed196c9 100644 --- a/.github/workflows/ci-workflows.yml +++ b/.github/workflows/ci-workflows.yml @@ -19,6 +19,19 @@ jobs: # Code style - linux: codestyle + tests: + needs: initial_checks + uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1 + with: + display: true + coverage: codecov + libraries: | + apt: + - '^libxcb.*-dev' + - libxkbcommon-x11-0 + - libegl1-mesa + - libhdf5-dev + deploy-examples: needs: initial_checks if: github.event_name != 'pull_request' diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..b976ba6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +comment: off +coverage: + status: + project: + default: + target: auto + # adjust accordingly based on how flaky your tests are + # this allows a 0.001% drop from the previous base commit coverage + # basically just to prevent counting zero changes as negative + threshold: 0.001% diff --git a/glue_ar/__init__.py b/glue_ar/__init__.py index 8692355..34604c6 100644 --- a/glue_ar/__init__.py +++ b/glue_ar/__init__.py @@ -1,5 +1,10 @@ -from glue_ar.common.scatter_export_options import * # noqa -from glue_ar.common.volume_export_options import * # noqa +from pkg_resources import get_distribution, DistributionNotFound +import traceback + +try: + __version__ = get_distribution(__name__).version +except DistributionNotFound: + pass def setup_qt(): @@ -8,7 +13,7 @@ def setup_qt(): except ImportError: from glue_vispy_viewers.scatter.scatter_viewer import VispyScatterViewer - from glue_ar.qt.export_tool import QtARExportTool # noqa + from .qt.export_tool import QtARExportTool # noqa VispyScatterViewer.subtools = { **VispyScatterViewer.subtools, @@ -26,7 +31,7 @@ def setup_qt(): } try: - from glue_ar.qt.qr_tool import ARLocalQRTool # noqa + from .qt.qr_tool import ARLocalQRTool # noqa VispyScatterViewer.tools = [t for t in VispyScatterViewer.tools] + ["ar"] VispyVolumeViewer.tools = [t for t in VispyVolumeViewer.tools] + ["ar"] VispyScatterViewer.subtools["ar"] = ["ar:qr"] @@ -36,7 +41,7 @@ def setup_qt(): def setup_jupyter(): - from glue_ar.jupyter.export_tool import JupyterARExportTool # noqa + from .jupyter.export_tool import JupyterARExportTool # noqa from glue_vispy_viewers.scatter.jupyter import JupyterVispyScatterViewer from glue_vispy_viewers.volume.jupyter import JupyterVispyVolumeViewer JupyterVispyScatterViewer.tools = [t for t in JupyterVispyScatterViewer.tools] + ["save:ar_jupyter"] @@ -46,14 +51,14 @@ def setup_jupyter(): def setup(): try: setup_qt() - except ImportError as e: + except ImportError: print("Qt setup error") - print(e) + print(traceback.format_exc()) pass try: setup_jupyter() - except ImportError as e: + except ImportError: print("Jupyter setup error") - print(e) + print(traceback.format_exc()) pass diff --git a/glue_ar/common/__init__.py b/glue_ar/common/__init__.py index e610bd3..9e787ec 100644 --- a/glue_ar/common/__init__.py +++ b/glue_ar/common/__init__.py @@ -1,3 +1,5 @@ from .marching_cubes import add_isosurface_layer_gltf, add_isosurface_layer_usd # noqa: F401 from .scatter import add_scatter_layer_gltf, add_scatter_layer_usd # noqa: F401 from .voxels import add_voxel_layers_gltf, add_voxel_layers_usd # noqa: F401 +from .scatter_export_options import * # noqa: F401 +from .volume_export_options import * # noqa: F401 diff --git a/glue_ar/common/tests/test_scatter_gltf.py b/glue_ar/common/tests/test_scatter_gltf.py new file mode 100644 index 0000000..5dae317 --- /dev/null +++ b/glue_ar/common/tests/test_scatter_gltf.py @@ -0,0 +1,5 @@ +from gltflib.gltf import GLTF + + +class TestScatterGLTF: + pass diff --git a/glue_ar/common/tests/test_shapes.py b/glue_ar/common/tests/test_shapes.py index 11c99ed..11e0be8 100644 --- a/glue_ar/common/tests/test_shapes.py +++ b/glue_ar/common/tests/test_shapes.py @@ -1,6 +1,7 @@ from itertools import product from math import sqrt -from ..shapes import cone_points, cylinder_points, rectangular_prism_points, sphere_points +import pytest +from glue_ar.common.shapes import cone_points, cone_triangles, cylinder_points, cylinder_triangles, rectangular_prism_points, rectangular_prism_triangulation, sphere_points, sphere_triangles class TestShapes: @@ -20,6 +21,38 @@ def test_rectangular_prism_points(self): (2.0, 1.5, -0.5), (2.0, 1.5, 2.5)} == points + def test_rectangular_prism_triangles(self): + triangles = set(rectangular_prism_triangulation()) + + assert {(0, 2, 1), + (0, 4, 2), + (1, 2, 3), + (4, 0, 1), + (4, 7, 6), + (5, 1, 7), + (5, 4, 1), + (6, 2, 4), + (7, 1, 3), + (7, 2, 6), + (7, 3, 2), + (7, 4, 5)} == triangles + + start_index = 5 + triangles = set(rectangular_prism_triangulation(start_index=start_index)) + + assert {(5, 7, 6), + (5, 9, 7), + (6, 7, 8), + (9, 5, 6), + (9, 12, 11), + (10, 6, 12), + (10, 9, 6), + (11, 7, 9), + (12, 6, 8), + (12, 7, 11), + (12, 8, 7), + (12, 9, 10)} == triangles + def test_sphere_points(self): center = (1, 2, 3) radius = 2 @@ -50,6 +83,21 @@ def test_sphere_points(self): assert points == expected + + @pytest.mark.parametrize("theta_resolution,phi_resolution", product((5, 8, 10, 12, 15, 20), repeat=2)) + def test_sphere_points_count(self, theta_resolution, phi_resolution): + points = sphere_points(center=(0, 0, 0,), radius=1, + theta_resolution=theta_resolution, + phi_resolution=phi_resolution) + assert len(points) == 2 + (theta_resolution - 2) * phi_resolution + + @pytest.mark.parametrize("theta_resolution,phi_resolution", product((5, 8, 10, 12, 15, 20), repeat=2)) + def test_sphere_triangles_count(self, theta_resolution, phi_resolution): + triangles = sphere_triangles(theta_resolution=theta_resolution, + phi_resolution=phi_resolution) + assert len(triangles) == 2 * phi_resolution * (theta_resolution - 2) + + def test_cylinder_points(self): center = (-1, 2, -5) radius = 3 @@ -70,7 +118,20 @@ def test_cylinder_points(self): assert points == expected - def test_points(self): + @pytest.mark.parametrize("theta_resolution", (3, 5, 8, 10, 12, 15, 20)) + def test_cylinder_points_count(self, theta_resolution): + points = cylinder_points(center=(0, 0, 0,), radius=1, + length=1, central_axis=(0, 0, 1), + theta_resolution=theta_resolution) + assert len(points) == 2 * theta_resolution + + @pytest.mark.parametrize("theta_resolution,start_index", product((3, 5, 8, 10, 12, 15), (0, 2, 5, 7, 10))) + def test_cylinder_triangles_count(self, theta_resolution, start_index): + triangles = cylinder_triangles(theta_resolution=theta_resolution, + start_index=start_index) + assert len(triangles) == 2 * (theta_resolution - 2) + 2 * theta_resolution + + def test_cone_points(self): center = (2, 0, -1) radius = 6 height = 10 @@ -86,3 +147,19 @@ def test_points(self): expected = {tuple(round(t, precision) for t in pt) for pt in expected} assert points == expected + + @pytest.mark.parametrize("theta_resolution", (3, 5, 8, 10, 12, 15, 20)) + def test_cone_points_count(self, theta_resolution): + points = cone_points(base_center=(0, 0, 0,), radius=1, + height=1, central_axis=(0, 0, 1), + theta_resolution=theta_resolution) + assert len(points) == theta_resolution + 1 + + @pytest.mark.parametrize("theta_resolution,start_index", product((3, 5, 8, 10, 12, 15), (0, 2, 5, 7, 10))) + def test_cone_triangles_count(self, theta_resolution, start_index): + triangles = cone_triangles(theta_resolution=theta_resolution, + start_index=start_index) + assert len(triangles) == 2 * theta_resolution - 2 + + + diff --git a/glue_ar/conftest.py b/glue_ar/conftest.py new file mode 100644 index 0000000..7e6a5f7 --- /dev/null +++ b/glue_ar/conftest.py @@ -0,0 +1,3 @@ +def pytest_configure(config): + from glue_ar import setup + setup() diff --git a/glue_ar/common/tests/__init__.py b/glue_ar/jupyter/__init__.py similarity index 100% rename from glue_ar/common/tests/__init__.py rename to glue_ar/jupyter/__init__.py diff --git a/glue_ar/qt/__init__.py b/glue_ar/qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/glue_ar/qt/qr.py b/glue_ar/qt/qr.py index ec0020d..2a4740f 100644 --- a/glue_ar/qt/qr.py +++ b/glue_ar/qt/qr.py @@ -5,7 +5,7 @@ import segno -GLUE_LOGO = os.path.abspath(os.path.join(os.path.dirname(__file__), "logo.png")) +GLUE_LOGO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "logo.png")) GLUE_RED = "#eb1c24" @@ -23,19 +23,20 @@ def get_local_ip(): return IP -def create_qr(url): +def create_qr(url, with_logo=True, color=GLUE_RED): qr = segno.make_qr(url) out = BytesIO() - qr.save(out, kind="png", scale=7, dark=GLUE_RED, light="white") + qr.save(out, kind="png", scale=7, dark=color, light="white") out.seek(0) img = Image.open(out) img = img.convert("RGB") - width, height = img.size - logo_max_size = height // 3 - logo_img = Image.open(GLUE_LOGO) - # Resize the logo to logo_max_size - logo_img.thumbnail((logo_max_size, logo_max_size), Image.Resampling.LANCZOS) - # Calculate the center of the QR code - box = ((width - logo_img.size[0]) // 2, (height - logo_img.size[1]) // 2) - img.paste(logo_img, box) + if with_logo: + width, height = img.size + logo_max_size = height // 3 + logo_img = Image.open(GLUE_LOGO) + # Resize the logo to logo_max_size + logo_img.thumbnail((logo_max_size, logo_max_size), Image.Resampling.LANCZOS) + # Calculate the center of the QR code + box = ((width - logo_img.size[0]) // 2, (height - logo_img.size[1]) // 2) + img.paste(logo_img, box) return img diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 3870a82..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,24 +0,0 @@ -[build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "glue-ar" -authors = [{name = "glue developers", email = "glue.viz@gmail.com"}] -description = "glue plugin for exporting augmented reality files" -readme = "README.md" -urls = {Homepage = "https://glueviz.org/"} -requires-python = ">=3.8" -dependencies = [ - "echo", - "gltflib", - "glue-core", - "glue-qt", - "glue-vispy-viewers>=1.2.1", - "numpy", - "pillow", - "qtpy", - "scipy", - "usd-core", -] -dynamic = ["version"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 75830ba..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[metadata] -name = glue-ar -description_file = README.md diff --git a/setup.py b/setup.py index 34c4098..bbb71fc 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,701 @@ import os import sys -sys.path.append(".") -from setupbase import ( - create_cmdclass, - install_npm, - combine_commands, +from setuptools import setup + +""" +This file contains content originating from the 'jupyter-packaging' package, and +contains a set of useful utilities for including npm packages +within a Python package. +""" +from collections import defaultdict +from os.path import join as pjoin +import io +import os +import functools +import pipes +import re +import shlex +import subprocess +import sys + + +# BEFORE importing distutils, remove MANIFEST. distutils doesn't properly +# update it when the contents of directories change. +if os.path.exists("MANIFEST"): + os.remove("MANIFEST") + + +from distutils.cmd import Command +from distutils import log + +try: + from setuptools.command.build_py import build_py + from setuptools.command.sdist import sdist +except ImportError: + from distutils.command.build_py import build_py + from distutils.command.sdist import sdist + +from setuptools.command.develop import develop +from setuptools.command.bdist_egg import bdist_egg + +try: + from wheel.bdist_wheel import bdist_wheel +except ImportError: + bdist_wheel = None + +if sys.platform == "win32": + from subprocess import list2cmdline +else: + + def list2cmdline(cmd_list): + return " ".join(map(pipes.quote, cmd_list)) + + +__version__ = "0.2.0" + +# --------------------------------------------------------------------------- +# Top Level Variables +# --------------------------------------------------------------------------- + +HERE = os.path.abspath(os.path.dirname(__file__)) +is_repo = os.path.exists(pjoin(HERE, ".git")) +node_modules = pjoin(HERE, "node_modules") + +SEPARATORS = os.sep if os.altsep is None else os.sep + os.altsep + +npm_path = ":".join( + [ + pjoin(HERE, "node_modules", ".bin"), + os.environ.get("PATH", os.defpath), + ] ) -from setuptools import setup +if "--skip-npm" in sys.argv: + print("Skipping npm install as requested.") + skip_npm = True + sys.argv.remove("--skip-npm") +else: + skip_npm = False + + +# --------------------------------------------------------------------------- +# Public Functions +# --------------------------------------------------------------------------- + + +def get_version(file, name="__version__"): + """Get the version of the package from the given file by + executing it and extracting the given `name`. + """ + path = os.path.realpath(file) + version_ns = {} + with io.open(path, encoding="utf8") as f: + exec(f.read(), {}, version_ns) + return version_ns[name] + + +def find_packages(top=HERE): + """ + Find all of the packages. + """ + packages = [] + for d, dirs, _ in os.walk(top, followlinks=True): + packages.append(os.path.relpath(d, HERE).replace(os.path.sep, ".")) + return packages + + +def update_package_data(distribution): + """update build_py options to get package_data changes""" + build_py = distribution.get_command_obj("build_py") + build_py.finalize_options() + + +class bdist_egg_disabled(bdist_egg): + """Disabled version of bdist_egg + + Prevents setup.py install performing setuptools' default easy_install, + which it should never ever do. + """ + + def run(self): + sys.exit( + "Aborting implicit building of eggs. Use `pip install .` " + " to install from source." + ) + + +def create_cmdclass(prerelease_cmd=None, package_data_spec=None, data_files_spec=None): + """Create a command class with the given optional prerelease class. + + Parameters + ---------- + prerelease_cmd: (name, Command) tuple, optional + The command to run before releasing. + package_data_spec: dict, optional + A dictionary whose keys are the dotted package names and + whose values are a list of glob patterns. + data_files_spec: list, optional + A list of (path, dname, pattern) tuples where the path is the + `data_files` install path, dname is the source directory, and the + pattern is a glob pattern. + + Notes + ----- + We use specs so that we can find the files *after* the build + command has run. + + The package data glob patterns should be relative paths from the package + folder containing the __init__.py file, which is given as the package + name. + e.g. `dict(foo=['./bar/*', './baz/**'])` + + The data files directories should be absolute paths or relative paths + from the root directory of the repository. Data files are specified + differently from `package_data` because we need a separate path entry + for each nested folder in `data_files`, and this makes it easier to + parse. + e.g. `('share/foo/bar', 'pkgname/bizz, '*')` + """ + wrapped = [prerelease_cmd] if prerelease_cmd else [] + if package_data_spec or data_files_spec: + wrapped.append("handle_files") + wrapper = functools.partial(_wrap_command, wrapped) + handle_files = _get_file_handler(package_data_spec, data_files_spec) + + if "bdist_egg" in sys.argv: + egg = wrapper(bdist_egg, strict=True) + else: + egg = bdist_egg_disabled + + cmdclass = dict( + build_py=wrapper(build_py, strict=is_repo), + bdist_egg=egg, + sdist=wrapper(sdist, strict=True), + handle_files=handle_files, + ) + + if bdist_wheel: + cmdclass["bdist_wheel"] = wrapper(bdist_wheel, strict=True) + + cmdclass["develop"] = wrapper(develop, strict=True) + return cmdclass + + +def command_for_func(func): + """Create a command that calls the given function.""" + + class FuncCommand(BaseCommand): + def run(self): + func() + update_package_data(self.distribution) + + return FuncCommand + + +def run(cmd, **kwargs): + """Echo a command before running it. Defaults to repo as cwd""" + log.info("> " + list2cmdline(cmd)) + kwargs.setdefault("cwd", HERE) + kwargs.setdefault("shell", os.name == "nt") + if not isinstance(cmd, (list, tuple)) and os.name != "nt": + cmd = shlex.split(cmd) + cmd[0] = which(cmd[0]) + return subprocess.check_call(cmd, **kwargs) + + +def is_stale(target, source): + """Test whether the target file/directory is stale based on the source + file/directory. + """ + if not os.path.exists(target): + return True + target_mtime = recursive_mtime(target) or 0 + return compare_recursive_mtime(source, cutoff=target_mtime) + + +class BaseCommand(Command): + """Empty command because Command needs subclasses to override too much""" + + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def get_inputs(self): + return [] + + def get_outputs(self): + return [] + + +def combine_commands(*commands): + """Return a Command that combines several commands.""" + + class CombinedCommand(Command): + user_options = [] + + def initialize_options(self): + self.commands = [] + for C in commands: + self.commands.append(C(self.distribution)) + for c in self.commands: + c.initialize_options() + + def finalize_options(self): + for c in self.commands: + c.finalize_options() + + def run(self): + for c in self.commands: + c.run() + + return CombinedCommand + + +def compare_recursive_mtime(path, cutoff, newest=True): + """Compare the newest/oldest mtime for all files in a directory. + + Cutoff should be another mtime to be compared against. If an mtime that is + newer/older than the cutoff is found it will return True. + E.g. if newest=True, and a file in path is newer than the cutoff, it will + return True. + """ + if os.path.isfile(path): + mt = mtime(path) + if newest: + if mt > cutoff: + return True + elif mt < cutoff: + return True + for dirname, _, filenames in os.walk(path, topdown=False): + for filename in filenames: + mt = mtime(pjoin(dirname, filename)) + if newest: # Put outside of loop? + if mt > cutoff: + return True + elif mt < cutoff: + return True + return False + + +def recursive_mtime(path, newest=True): + """Gets the newest/oldest mtime for all files in a directory.""" + if os.path.isfile(path): + return mtime(path) + current_extreme = None + for dirname, dirnames, filenames in os.walk(path, topdown=False): + for filename in filenames: + mt = mtime(pjoin(dirname, filename)) + if newest: # Put outside of loop? + if mt >= (current_extreme or mt): + current_extreme = mt + elif mt <= (current_extreme or mt): + current_extreme = mt + return current_extreme + + +def mtime(path): + """shorthand for mtime""" + return os.stat(path).st_mtime + + +def install_npm( + path=None, build_dir=None, source_dir=None, build_cmd="build", force=False, npm=None +): + """Return a Command for managing an npm installation. + + Note: The command is skipped if the `--skip-npm` flag is used. + + Parameters + ---------- + path: str, optional + The base path of the node package. Defaults to the repo root. + build_dir: str, optional + The target build directory. If this and source_dir are given, + the JavaScript will only be build if necessary. + source_dir: str, optional + The source code directory. + build_cmd: str, optional + The npm command to build assets to the build_dir. + npm: str or list, optional. + The npm executable name, or a tuple of ['node', executable]. + """ + + class NPM(BaseCommand): + description = "install package.json dependencies using npm" + + def run(self): + if skip_npm: + log.info("Skipping npm-installation") + return + node_package = path or HERE + node_modules = pjoin(node_package, "node_modules") + is_yarn = os.path.exists(pjoin(node_package, "yarn.lock")) + + npm_cmd = npm + + if npm is None: + if is_yarn: + npm_cmd = ["yarn"] + else: + npm_cmd = ["npm"] + + if not which(npm_cmd[0]): + log.error( + "`{0}` unavailable. If you're running this command " + "using sudo, make sure `{0}` is availble to sudo".format(npm_cmd[0]) + ) + return + + if force or is_stale(node_modules, pjoin(node_package, "package.json")): + log.info( + "Installing build dependencies with npm. This may " + "take a while..." + ) + run(npm_cmd + ["install"], cwd=node_package) + if build_dir and source_dir and not force: + should_build = is_stale(build_dir, source_dir) + else: + should_build = True + if should_build: + run(npm_cmd + ["run", build_cmd], cwd=node_package) + + return NPM + + +def ensure_targets(targets): + """Return a Command that checks that certain files exist. + + Raises a ValueError if any of the files are missing. + + Note: The check is skipped if the `--skip-npm` flag is used. + """ + + class TargetsCheck(BaseCommand): + def run(self): + if skip_npm: + log.info("Skipping target checks") + return + missing = [t for t in targets if not os.path.exists(t)] + if missing: + raise ValueError(("missing files: %s" % missing)) + + return TargetsCheck + + +# `shutils.which` function copied verbatim from the Python-3.3 source. +def which(cmd, mode=os.F_OK | os.X_OK, path=None): + """Given a command, mode, and a PATH string, return the path which + conforms to the given mode on the PATH, or None if there is no such + file. + `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result + of os.environ.get("PATH"), or can be overridden with a custom search + path. + """ + + # Check that a given file can be accessed with the correct mode. + # Additionally check that `file` is not a directory, as on Windows + # directories pass the os.access check. + def _access_check(fn, mode): + return os.path.exists(fn) and os.access(fn, mode) and not os.path.isdir(fn) + + # Short circuit. If we're given a full path which matches the mode + # and it exists, we're done here. + if _access_check(cmd, mode): + return cmd + + path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep) + + if sys.platform == "win32": + # The current directory takes precedence on Windows. + if os.curdir not in path: + path.insert(0, os.curdir) + + # PATHEXT is necessary to check on Windows. + pathext = os.environ.get("PATHEXT", "").split(os.pathsep) + # See if the given file matches any of the expected path extensions. + # This will allow us to short circuit when given "python.exe". + matches = [cmd for ext in pathext if cmd.lower().endswith(ext.lower())] + # If it does match, only test that one, otherwise we have to try + # others. + files = [cmd] if matches else [cmd + ext.lower() for ext in pathext] + else: + # On other platforms you don't have things like PATHEXT to tell you + # what file suffixes are executable, so just pass on cmd as-is. + files = [cmd] + + seen = set() + for dir in path: + dir = os.path.normcase(dir) + if dir not in seen: + seen.add(dir) + for thefile in files: + name = os.path.join(dir, thefile) + if _access_check(name, mode): + return name + return None + + +# --------------------------------------------------------------------------- +# Private Functions +# --------------------------------------------------------------------------- + + +def _wrap_command(cmds, cls, strict=True): + """Wrap a setup command + + Parameters + ---------- + cmds: list(str) + The names of the other commands to run prior to the command. + strict: boolean, optional + Wether to raise errors when a pre-command fails. + """ + + class WrappedCommand(cls): + def run(self): + if not getattr(self, "uninstall", None): + try: + [self.run_command(cmd) for cmd in cmds] + except Exception: + if strict: + raise + else: + pass + # update package data + update_package_data(self.distribution) + + result = cls.run(self) + return result + + return WrappedCommand + + +def _get_file_handler(package_data_spec, data_files_spec): + """Get a package_data and data_files handler command.""" + + class FileHandler(BaseCommand): + def run(self): + package_data = self.distribution.package_data + package_spec = package_data_spec or dict() + + for (key, patterns) in package_spec.items(): + package_data[key] = _get_package_data(key, patterns) + + self.distribution.data_files = _get_data_files( + data_files_spec, self.distribution.data_files + ) + + return FileHandler + + +def _get_data_files(data_specs, existing): + """Expand data file specs into valid data files metadata. + + Parameters + ---------- + data_specs: list of tuples + See [createcmdclass] for description. + existing: list of tuples + The existing distrubution data_files metadata. + + Returns + ------- + A valid list of data_files items. + """ + # Extract the existing data files into a staging object. + file_data = defaultdict(list) + for (path, files) in existing or []: + file_data[path] = files + + # Extract the files and assign them to the proper data + # files path. + for (path, dname, pattern) in data_specs or []: + dname = dname.replace(os.sep, "/") + offset = len(dname) + 1 + + files = _get_files(pjoin(dname, pattern)) + for fname in files: + # Normalize the path. + root = os.path.dirname(fname) + full_path = "/".join([path, root[offset:]]) + if full_path.endswith("/"): + full_path = full_path[:-1] + file_data[full_path].append(fname) + + # Construct the data files spec. + data_files = [] + for (path, files) in file_data.items(): + data_files.append((path, files)) + return data_files + + +def _get_files(file_patterns, top=HERE): + """Expand file patterns to a list of paths. + + Parameters + ----------- + file_patterns: list or str + A list of glob patterns for the data file locations. + The globs can be recursive if they include a `**`. + They should be relative paths from the top directory or + absolute paths. + top: str + the directory to consider for data files + + Note: + Files in `node_modules` are ignored. + """ + if not isinstance(file_patterns, (list, tuple)): + file_patterns = [file_patterns] + + for i, p in enumerate(file_patterns): + if os.path.isabs(p): + file_patterns[i] = os.path.relpath(p, top) + + matchers = [_compile_pattern(p) for p in file_patterns] + + files = set() + + for root, dirnames, filenames in os.walk(top): + # Don't recurse into node_modules + if "node_modules" in dirnames: + dirnames.remove("node_modules") + for m in matchers: + for filename in filenames: + fn = os.path.relpath(pjoin(root, filename), top) + if m(fn): + files.add(fn.replace(os.sep, "/")) + + return list(files) + + +def _get_package_data(root, file_patterns=None): + """Expand file patterns to a list of `package_data` paths. + + Parameters + ----------- + root: str + The relative path to the package root from `HERE`. + file_patterns: list or str, optional + A list of glob patterns for the data file locations. + The globs can be recursive if they include a `**`. + They should be relative paths from the root or + absolute paths. If not given, all files will be used. + + Note: + Files in `node_modules` are ignored. + """ + if file_patterns is None: + file_patterns = ["*"] + return _get_files(file_patterns, pjoin(HERE, root)) + + +def _compile_pattern(pat, ignore_case=True): + """Translate and compile a glob pattern to a regular expression matcher.""" + if isinstance(pat, bytes): + pat_str = pat.decode("ISO-8859-1") + res_str = _translate_glob(pat_str) + res = res_str.encode("ISO-8859-1") + else: + res = _translate_glob(pat) + flags = re.IGNORECASE if ignore_case else 0 + return re.compile(res, flags=flags).match + + +def _iexplode_path(path): + """Iterate over all the parts of a path. + + Splits path recursively with os.path.split(). + """ + (head, tail) = os.path.split(path) + if not head or (not tail and head == path): + if head: + yield head + if tail or not head: + yield tail + return + for p in _iexplode_path(head): + yield p + yield tail + + +def _translate_glob(pat): + """Translate a glob PATTERN to a regular expression.""" + translated_parts = [] + for part in _iexplode_path(pat): + translated_parts.append(_translate_glob_part(part)) + os_sep_class = "[%s]" % re.escape(SEPARATORS) + res = _join_translated(translated_parts, os_sep_class) + return f"(?ms){res}\\Z" + + +def _join_translated(translated_parts, os_sep_class): + """Join translated glob pattern parts. + + This is different from a simple join, as care need to be taken + to allow ** to match ZERO or more directories. + """ + res = "" + for part in translated_parts[:-1]: + if part == ".*": + # drop separator, since it is optional + # (** matches ZERO or more dirs) + res += part + else: + res += part + os_sep_class + + if translated_parts[-1] == ".*": + # Final part is ** + res += ".+" + # Follow stdlib/git convention of matching all sub files/directories: + res += "({os_sep_class}?.*)?".format(os_sep_class=os_sep_class) + else: + res += translated_parts[-1] + return res + + +def _translate_glob_part(pat): + """Translate a glob PATTERN PART to a regular expression.""" + # Code modified from Python 3 standard lib fnmatch: + if pat == "**": + return ".*" + i, n = 0, len(pat) + res = [] + while i < n: + c = pat[i] + i = i + 1 + if c == "*": + # Match anything but path separators: + res.append("[^%s]*" % SEPARATORS) + elif c == "?": + res.append("[^%s]?" % SEPARATORS) + elif c == "[": + j = i + if j < n and pat[j] == "!": + j = j + 1 + if j < n and pat[j] == "]": + j = j + 1 + while j < n and pat[j] != "]": + j = j + 1 + if j >= n: + res.append("\\[") + else: + stuff = pat[i:j].replace("\\", "\\\\") + i = j + 1 + if stuff[0] == "!": + stuff = "^" + stuff[1:] + elif stuff[0] == "^": + stuff = "\\" + stuff + res.append("[%s]" % stuff) + else: + res.append(re.escape(c)) + return "".join(res) + def data_files(root_directory): paths = [] @@ -50,14 +737,19 @@ def data_files(root_directory): "glue-core", "glue-vispy-viewers", "pillow", + "PyMCubes", "usd-core", ], extras_require={ "test": [ - "flake8" + "flake8", + "pytest", + "pytest-cov", + "mock", ], "qt": [ - "glue-qt" + "glue-qt", + "PyQt5", ], "jupyter": [ "glue-jupyter", diff --git a/setupbase.py b/setupbase.py deleted file mode 100644 index 221c716..0000000 --- a/setupbase.py +++ /dev/null @@ -1,698 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 - -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -""" -This file originates from the 'jupyter-packaging' package, and -contains a set of useful utilities for including npm packages -within a Python package. -""" -from collections import defaultdict -from os.path import join as pjoin -import io -import os -import functools -import pipes -import re -import shlex -import subprocess -import sys - - -# BEFORE importing distutils, remove MANIFEST. distutils doesn't properly -# update it when the contents of directories change. -if os.path.exists("MANIFEST"): - os.remove("MANIFEST") - - -from distutils.cmd import Command -from distutils import log - -try: - from setuptools.command.build_py import build_py - from setuptools.command.sdist import sdist -except ImportError: - from distutils.command.build_py import build_py - from distutils.command.sdist import sdist - -from setuptools.command.develop import develop -from setuptools.command.bdist_egg import bdist_egg - -try: - from wheel.bdist_wheel import bdist_wheel -except ImportError: - bdist_wheel = None - -if sys.platform == "win32": - from subprocess import list2cmdline -else: - - def list2cmdline(cmd_list): - return " ".join(map(pipes.quote, cmd_list)) - - -__version__ = "0.2.0" - -# --------------------------------------------------------------------------- -# Top Level Variables -# --------------------------------------------------------------------------- - -HERE = os.path.abspath(os.path.dirname(__file__)) -is_repo = os.path.exists(pjoin(HERE, ".git")) -node_modules = pjoin(HERE, "node_modules") - -SEPARATORS = os.sep if os.altsep is None else os.sep + os.altsep - -npm_path = ":".join( - [ - pjoin(HERE, "node_modules", ".bin"), - os.environ.get("PATH", os.defpath), - ] -) - -if "--skip-npm" in sys.argv: - print("Skipping npm install as requested.") - skip_npm = True - sys.argv.remove("--skip-npm") -else: - skip_npm = False - - -# --------------------------------------------------------------------------- -# Public Functions -# --------------------------------------------------------------------------- - - -def get_version(file, name="__version__"): - """Get the version of the package from the given file by - executing it and extracting the given `name`. - """ - path = os.path.realpath(file) - version_ns = {} - with io.open(path, encoding="utf8") as f: - exec(f.read(), {}, version_ns) - return version_ns[name] - - -def find_packages(top=HERE): - """ - Find all of the packages. - """ - packages = [] - for d, dirs, _ in os.walk(top, followlinks=True): - packages.append(os.path.relpath(d, HERE).replace(os.path.sep, ".")) - return packages - - -def update_package_data(distribution): - """update build_py options to get package_data changes""" - build_py = distribution.get_command_obj("build_py") - build_py.finalize_options() - - -class bdist_egg_disabled(bdist_egg): - """Disabled version of bdist_egg - - Prevents setup.py install performing setuptools' default easy_install, - which it should never ever do. - """ - - def run(self): - sys.exit( - "Aborting implicit building of eggs. Use `pip install .` " - " to install from source." - ) - - -def create_cmdclass(prerelease_cmd=None, package_data_spec=None, data_files_spec=None): - """Create a command class with the given optional prerelease class. - - Parameters - ---------- - prerelease_cmd: (name, Command) tuple, optional - The command to run before releasing. - package_data_spec: dict, optional - A dictionary whose keys are the dotted package names and - whose values are a list of glob patterns. - data_files_spec: list, optional - A list of (path, dname, pattern) tuples where the path is the - `data_files` install path, dname is the source directory, and the - pattern is a glob pattern. - - Notes - ----- - We use specs so that we can find the files *after* the build - command has run. - - The package data glob patterns should be relative paths from the package - folder containing the __init__.py file, which is given as the package - name. - e.g. `dict(foo=['./bar/*', './baz/**'])` - - The data files directories should be absolute paths or relative paths - from the root directory of the repository. Data files are specified - differently from `package_data` because we need a separate path entry - for each nested folder in `data_files`, and this makes it easier to - parse. - e.g. `('share/foo/bar', 'pkgname/bizz, '*')` - """ - wrapped = [prerelease_cmd] if prerelease_cmd else [] - if package_data_spec or data_files_spec: - wrapped.append("handle_files") - wrapper = functools.partial(_wrap_command, wrapped) - handle_files = _get_file_handler(package_data_spec, data_files_spec) - - if "bdist_egg" in sys.argv: - egg = wrapper(bdist_egg, strict=True) - else: - egg = bdist_egg_disabled - - cmdclass = dict( - build_py=wrapper(build_py, strict=is_repo), - bdist_egg=egg, - sdist=wrapper(sdist, strict=True), - handle_files=handle_files, - ) - - if bdist_wheel: - cmdclass["bdist_wheel"] = wrapper(bdist_wheel, strict=True) - - cmdclass["develop"] = wrapper(develop, strict=True) - return cmdclass - - -def command_for_func(func): - """Create a command that calls the given function.""" - - class FuncCommand(BaseCommand): - def run(self): - func() - update_package_data(self.distribution) - - return FuncCommand - - -def run(cmd, **kwargs): - """Echo a command before running it. Defaults to repo as cwd""" - log.info("> " + list2cmdline(cmd)) - kwargs.setdefault("cwd", HERE) - kwargs.setdefault("shell", os.name == "nt") - if not isinstance(cmd, (list, tuple)) and os.name != "nt": - cmd = shlex.split(cmd) - cmd[0] = which(cmd[0]) - return subprocess.check_call(cmd, **kwargs) - - -def is_stale(target, source): - """Test whether the target file/directory is stale based on the source - file/directory. - """ - if not os.path.exists(target): - return True - target_mtime = recursive_mtime(target) or 0 - return compare_recursive_mtime(source, cutoff=target_mtime) - - -class BaseCommand(Command): - """Empty command because Command needs subclasses to override too much""" - - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def get_inputs(self): - return [] - - def get_outputs(self): - return [] - - -def combine_commands(*commands): - """Return a Command that combines several commands.""" - - class CombinedCommand(Command): - user_options = [] - - def initialize_options(self): - self.commands = [] - for C in commands: - self.commands.append(C(self.distribution)) - for c in self.commands: - c.initialize_options() - - def finalize_options(self): - for c in self.commands: - c.finalize_options() - - def run(self): - for c in self.commands: - c.run() - - return CombinedCommand - - -def compare_recursive_mtime(path, cutoff, newest=True): - """Compare the newest/oldest mtime for all files in a directory. - - Cutoff should be another mtime to be compared against. If an mtime that is - newer/older than the cutoff is found it will return True. - E.g. if newest=True, and a file in path is newer than the cutoff, it will - return True. - """ - if os.path.isfile(path): - mt = mtime(path) - if newest: - if mt > cutoff: - return True - elif mt < cutoff: - return True - for dirname, _, filenames in os.walk(path, topdown=False): - for filename in filenames: - mt = mtime(pjoin(dirname, filename)) - if newest: # Put outside of loop? - if mt > cutoff: - return True - elif mt < cutoff: - return True - return False - - -def recursive_mtime(path, newest=True): - """Gets the newest/oldest mtime for all files in a directory.""" - if os.path.isfile(path): - return mtime(path) - current_extreme = None - for dirname, dirnames, filenames in os.walk(path, topdown=False): - for filename in filenames: - mt = mtime(pjoin(dirname, filename)) - if newest: # Put outside of loop? - if mt >= (current_extreme or mt): - current_extreme = mt - elif mt <= (current_extreme or mt): - current_extreme = mt - return current_extreme - - -def mtime(path): - """shorthand for mtime""" - return os.stat(path).st_mtime - - -def install_npm( - path=None, build_dir=None, source_dir=None, build_cmd="build", force=False, npm=None -): - """Return a Command for managing an npm installation. - - Note: The command is skipped if the `--skip-npm` flag is used. - - Parameters - ---------- - path: str, optional - The base path of the node package. Defaults to the repo root. - build_dir: str, optional - The target build directory. If this and source_dir are given, - the JavaScript will only be build if necessary. - source_dir: str, optional - The source code directory. - build_cmd: str, optional - The npm command to build assets to the build_dir. - npm: str or list, optional. - The npm executable name, or a tuple of ['node', executable]. - """ - - class NPM(BaseCommand): - description = "install package.json dependencies using npm" - - def run(self): - if skip_npm: - log.info("Skipping npm-installation") - return - node_package = path or HERE - node_modules = pjoin(node_package, "node_modules") - is_yarn = os.path.exists(pjoin(node_package, "yarn.lock")) - - npm_cmd = npm - - if npm is None: - if is_yarn: - npm_cmd = ["yarn"] - else: - npm_cmd = ["npm"] - - if not which(npm_cmd[0]): - log.error( - "`{0}` unavailable. If you're running this command " - "using sudo, make sure `{0}` is availble to sudo".format(npm_cmd[0]) - ) - return - - if force or is_stale(node_modules, pjoin(node_package, "package.json")): - log.info( - "Installing build dependencies with npm. This may " - "take a while..." - ) - run(npm_cmd + ["install"], cwd=node_package) - if build_dir and source_dir and not force: - should_build = is_stale(build_dir, source_dir) - else: - should_build = True - if should_build: - run(npm_cmd + ["run", build_cmd], cwd=node_package) - - return NPM - - -def ensure_targets(targets): - """Return a Command that checks that certain files exist. - - Raises a ValueError if any of the files are missing. - - Note: The check is skipped if the `--skip-npm` flag is used. - """ - - class TargetsCheck(BaseCommand): - def run(self): - if skip_npm: - log.info("Skipping target checks") - return - missing = [t for t in targets if not os.path.exists(t)] - if missing: - raise ValueError(("missing files: %s" % missing)) - - return TargetsCheck - - -# `shutils.which` function copied verbatim from the Python-3.3 source. -def which(cmd, mode=os.F_OK | os.X_OK, path=None): - """Given a command, mode, and a PATH string, return the path which - conforms to the given mode on the PATH, or None if there is no such - file. - `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result - of os.environ.get("PATH"), or can be overridden with a custom search - path. - """ - - # Check that a given file can be accessed with the correct mode. - # Additionally check that `file` is not a directory, as on Windows - # directories pass the os.access check. - def _access_check(fn, mode): - return os.path.exists(fn) and os.access(fn, mode) and not os.path.isdir(fn) - - # Short circuit. If we're given a full path which matches the mode - # and it exists, we're done here. - if _access_check(cmd, mode): - return cmd - - path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep) - - if sys.platform == "win32": - # The current directory takes precedence on Windows. - if os.curdir not in path: - path.insert(0, os.curdir) - - # PATHEXT is necessary to check on Windows. - pathext = os.environ.get("PATHEXT", "").split(os.pathsep) - # See if the given file matches any of the expected path extensions. - # This will allow us to short circuit when given "python.exe". - matches = [cmd for ext in pathext if cmd.lower().endswith(ext.lower())] - # If it does match, only test that one, otherwise we have to try - # others. - files = [cmd] if matches else [cmd + ext.lower() for ext in pathext] - else: - # On other platforms you don't have things like PATHEXT to tell you - # what file suffixes are executable, so just pass on cmd as-is. - files = [cmd] - - seen = set() - for dir in path: - dir = os.path.normcase(dir) - if dir not in seen: - seen.add(dir) - for thefile in files: - name = os.path.join(dir, thefile) - if _access_check(name, mode): - return name - return None - - -# --------------------------------------------------------------------------- -# Private Functions -# --------------------------------------------------------------------------- - - -def _wrap_command(cmds, cls, strict=True): - """Wrap a setup command - - Parameters - ---------- - cmds: list(str) - The names of the other commands to run prior to the command. - strict: boolean, optional - Wether to raise errors when a pre-command fails. - """ - - class WrappedCommand(cls): - def run(self): - if not getattr(self, "uninstall", None): - try: - [self.run_command(cmd) for cmd in cmds] - except Exception: - if strict: - raise - else: - pass - # update package data - update_package_data(self.distribution) - - result = cls.run(self) - return result - - return WrappedCommand - - -def _get_file_handler(package_data_spec, data_files_spec): - """Get a package_data and data_files handler command.""" - - class FileHandler(BaseCommand): - def run(self): - package_data = self.distribution.package_data - package_spec = package_data_spec or dict() - - for (key, patterns) in package_spec.items(): - package_data[key] = _get_package_data(key, patterns) - - self.distribution.data_files = _get_data_files( - data_files_spec, self.distribution.data_files - ) - - return FileHandler - - -def _get_data_files(data_specs, existing): - """Expand data file specs into valid data files metadata. - - Parameters - ---------- - data_specs: list of tuples - See [createcmdclass] for description. - existing: list of tuples - The existing distrubution data_files metadata. - - Returns - ------- - A valid list of data_files items. - """ - # Extract the existing data files into a staging object. - file_data = defaultdict(list) - for (path, files) in existing or []: - file_data[path] = files - - # Extract the files and assign them to the proper data - # files path. - for (path, dname, pattern) in data_specs or []: - dname = dname.replace(os.sep, "/") - offset = len(dname) + 1 - - files = _get_files(pjoin(dname, pattern)) - for fname in files: - # Normalize the path. - root = os.path.dirname(fname) - full_path = "/".join([path, root[offset:]]) - if full_path.endswith("/"): - full_path = full_path[:-1] - file_data[full_path].append(fname) - - # Construct the data files spec. - data_files = [] - for (path, files) in file_data.items(): - data_files.append((path, files)) - return data_files - - -def _get_files(file_patterns, top=HERE): - """Expand file patterns to a list of paths. - - Parameters - ----------- - file_patterns: list or str - A list of glob patterns for the data file locations. - The globs can be recursive if they include a `**`. - They should be relative paths from the top directory or - absolute paths. - top: str - the directory to consider for data files - - Note: - Files in `node_modules` are ignored. - """ - if not isinstance(file_patterns, (list, tuple)): - file_patterns = [file_patterns] - - for i, p in enumerate(file_patterns): - if os.path.isabs(p): - file_patterns[i] = os.path.relpath(p, top) - - matchers = [_compile_pattern(p) for p in file_patterns] - - files = set() - - for root, dirnames, filenames in os.walk(top): - # Don't recurse into node_modules - if "node_modules" in dirnames: - dirnames.remove("node_modules") - for m in matchers: - for filename in filenames: - fn = os.path.relpath(pjoin(root, filename), top) - if m(fn): - files.add(fn.replace(os.sep, "/")) - - return list(files) - - -def _get_package_data(root, file_patterns=None): - """Expand file patterns to a list of `package_data` paths. - - Parameters - ----------- - root: str - The relative path to the package root from `HERE`. - file_patterns: list or str, optional - A list of glob patterns for the data file locations. - The globs can be recursive if they include a `**`. - They should be relative paths from the root or - absolute paths. If not given, all files will be used. - - Note: - Files in `node_modules` are ignored. - """ - if file_patterns is None: - file_patterns = ["*"] - return _get_files(file_patterns, pjoin(HERE, root)) - - -def _compile_pattern(pat, ignore_case=True): - """Translate and compile a glob pattern to a regular expression matcher.""" - if isinstance(pat, bytes): - pat_str = pat.decode("ISO-8859-1") - res_str = _translate_glob(pat_str) - res = res_str.encode("ISO-8859-1") - else: - res = _translate_glob(pat) - flags = re.IGNORECASE if ignore_case else 0 - return re.compile(res, flags=flags).match - - -def _iexplode_path(path): - """Iterate over all the parts of a path. - - Splits path recursively with os.path.split(). - """ - (head, tail) = os.path.split(path) - if not head or (not tail and head == path): - if head: - yield head - if tail or not head: - yield tail - return - for p in _iexplode_path(head): - yield p - yield tail - - -def _translate_glob(pat): - """Translate a glob PATTERN to a regular expression.""" - translated_parts = [] - for part in _iexplode_path(pat): - translated_parts.append(_translate_glob_part(part)) - os_sep_class = "[%s]" % re.escape(SEPARATORS) - res = _join_translated(translated_parts, os_sep_class) - return f"(?ms){res}\\Z" - - -def _join_translated(translated_parts, os_sep_class): - """Join translated glob pattern parts. - - This is different from a simple join, as care need to be taken - to allow ** to match ZERO or more directories. - """ - res = "" - for part in translated_parts[:-1]: - if part == ".*": - # drop separator, since it is optional - # (** matches ZERO or more dirs) - res += part - else: - res += part + os_sep_class - - if translated_parts[-1] == ".*": - # Final part is ** - res += ".+" - # Follow stdlib/git convention of matching all sub files/directories: - res += "({os_sep_class}?.*)?".format(os_sep_class=os_sep_class) - else: - res += translated_parts[-1] - return res - - -def _translate_glob_part(pat): - """Translate a glob PATTERN PART to a regular expression.""" - # Code modified from Python 3 standard lib fnmatch: - if pat == "**": - return ".*" - i, n = 0, len(pat) - res = [] - while i < n: - c = pat[i] - i = i + 1 - if c == "*": - # Match anything but path separators: - res.append("[^%s]*" % SEPARATORS) - elif c == "?": - res.append("[^%s]?" % SEPARATORS) - elif c == "[": - j = i - if j < n and pat[j] == "!": - j = j + 1 - if j < n and pat[j] == "]": - j = j + 1 - while j < n and pat[j] != "]": - j = j + 1 - if j >= n: - res.append("\\[") - else: - stuff = pat[i:j].replace("\\", "\\\\") - i = j + 1 - if stuff[0] == "!": - stuff = "^" + stuff[1:] - elif stuff[0] == "^": - stuff = "\\" + stuff - res.append("[%s]" % stuff) - else: - res.append(re.escape(c)) - return "".join(res) diff --git a/tox.ini b/tox.ini index e698deb..258a376 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,8 @@ passenv = HOME changedir = test: .tmp/{envname} +extras = + test: test,qt,jupyter commands = test: pip freeze test: pytest --pyargs glue_ar --cov glue_ar {posargs} From 1b13e80bd7704d3e7b04cffae4701336e4866a16 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 02:34:46 -0400 Subject: [PATCH 02/10] More progress on tox setup. --- glue_ar/py.typed | 0 setup.py | 11 ++++++++--- tox.ini | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 glue_ar/py.typed diff --git a/glue_ar/py.typed b/glue_ar/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index bbb71fc..3d0990b 100644 --- a/setup.py +++ b/setup.py @@ -95,13 +95,15 @@ def get_version(file, name="__version__"): return version_ns[name] -def find_packages(top=HERE): +def find_packages(top=HERE, exclude=None): """ Find all of the packages. """ packages = [] for d, dirs, _ in os.walk(top, followlinks=True): packages.append(os.path.relpath(d, HERE).replace(os.path.sep, ".")) + if exclude: + packages = [p for p in packages if p not in exclude] return packages @@ -707,7 +709,7 @@ def data_files(root_directory): name = "glue_ar" js_content_command = combine_commands( - install_npm("js", build_cmd="glue-ar-export") + install_npm("js", build_cmd="glue-ar-export", force=True) ) # Custom "command class" that (1) makes sure to create the JS content, (2) @@ -730,7 +732,10 @@ def data_files(root_directory): cmdclass=cmdclass, python_requires=">=3.8", zip_safe=False, - packages=[name], + packages=find_packages(name, exclude=["js"]), + package_data={ + "glue_ar": ["py.typed"], + }, include_package_data=True, install_requires=[ "gltflib", diff --git a/tox.ini b/tox.ini index 258a376..078a7e7 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ changedir = extras = test: test,qt,jupyter commands = + test: node js/glue-ar-export.js test: pip freeze test: pytest --pyargs glue_ar --cov glue_ar {posargs} From 944ac40c8b414f566d20fbb43b722910c4be50bd Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 03:05:29 -0400 Subject: [PATCH 03/10] Tox now runs tests successfully. --- setup.py | 5 +++-- tox.ini | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 3d0990b..e776eb1 100644 --- a/setup.py +++ b/setup.py @@ -100,10 +100,11 @@ def find_packages(top=HERE, exclude=None): Find all of the packages. """ packages = [] + exclude = exclude or [] for d, dirs, _ in os.walk(top, followlinks=True): + if any(d.startswith(os.path.join(top, ex)) for ex in exclude): + continue packages.append(os.path.relpath(d, HERE).replace(os.path.sep, ".")) - if exclude: - packages = [p for p in packages if p not in exclude] return packages diff --git a/tox.ini b/tox.ini index 078a7e7..258a376 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,6 @@ changedir = extras = test: test,qt,jupyter commands = - test: node js/glue-ar-export.js test: pip freeze test: pytest --pyargs glue_ar --cov glue_ar {posargs} From 92ce3693ec9ae1e718aafeb36c3058617a673c6b Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 10:03:37 -0400 Subject: [PATCH 04/10] Add tox environments to CI workflow. --- .github/workflows/ci-workflows.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci-workflows.yml b/.github/workflows/ci-workflows.yml index ed196c9..44c0169 100644 --- a/.github/workflows/ci-workflows.yml +++ b/.github/workflows/ci-workflows.yml @@ -31,6 +31,25 @@ jobs: - libxkbcommon-x11-0 - libegl1-mesa - libhdf5-dev + envs: | + - linux: py38-test + - linux: py39-test + - linux: py310-test + - linux: py311-test + - linux: py312-test + + - macos: py38-test + - macos: py39-test + - macos: py310-test + - macos: py311-test + - macos: py312-test + + - windows: py38-test + - windows: py39-test + - windows: py310-test + - windows: py311-test + - windows: py312-test + deploy-examples: needs: initial_checks From 0f367793d952fbbbdb0c91acc2da3a71373bbc26 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 10:05:03 -0400 Subject: [PATCH 05/10] Don't need to force npm install. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e776eb1..c5e16ad 100644 --- a/setup.py +++ b/setup.py @@ -710,7 +710,7 @@ def data_files(root_directory): name = "glue_ar" js_content_command = combine_commands( - install_npm("js", build_cmd="glue-ar-export", force=True) + install_npm("js", build_cmd="glue-ar-export") ) # Custom "command class" that (1) makes sure to create the JS content, (2) From deea4585073c3740d3c067ae623e4b1abff21aa1 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 10:06:32 -0400 Subject: [PATCH 06/10] Re-add setup.cfg --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..75830ba --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[metadata] +name = glue-ar +description_file = README.md From e3b343ee0b7717644d8708ac3373c323f8447947 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 10:27:57 -0400 Subject: [PATCH 07/10] Codestyle fixes. --- glue_ar/common/__init__.py | 4 ++-- glue_ar/common/tests/test_scatter_gltf.py | 3 --- glue_ar/common/tests/test_shapes.py | 12 +++++------- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/glue_ar/common/__init__.py b/glue_ar/common/__init__.py index 9e787ec..5112e28 100644 --- a/glue_ar/common/__init__.py +++ b/glue_ar/common/__init__.py @@ -1,5 +1,5 @@ from .marching_cubes import add_isosurface_layer_gltf, add_isosurface_layer_usd # noqa: F401 from .scatter import add_scatter_layer_gltf, add_scatter_layer_usd # noqa: F401 from .voxels import add_voxel_layers_gltf, add_voxel_layers_usd # noqa: F401 -from .scatter_export_options import * # noqa: F401 -from .volume_export_options import * # noqa: F401 +from .scatter_export_options import ARScatterExportOptions # noqa: F401 +from .volume_export_options import ARIsosurfaceExportOptions, ARVoxelExportOptions # noqa: F401 diff --git a/glue_ar/common/tests/test_scatter_gltf.py b/glue_ar/common/tests/test_scatter_gltf.py index 5dae317..c65ace3 100644 --- a/glue_ar/common/tests/test_scatter_gltf.py +++ b/glue_ar/common/tests/test_scatter_gltf.py @@ -1,5 +1,2 @@ -from gltflib.gltf import GLTF - - class TestScatterGLTF: pass diff --git a/glue_ar/common/tests/test_shapes.py b/glue_ar/common/tests/test_shapes.py index 11e0be8..b7f09a8 100644 --- a/glue_ar/common/tests/test_shapes.py +++ b/glue_ar/common/tests/test_shapes.py @@ -1,7 +1,10 @@ from itertools import product from math import sqrt import pytest -from glue_ar.common.shapes import cone_points, cone_triangles, cylinder_points, cylinder_triangles, rectangular_prism_points, rectangular_prism_triangulation, sphere_points, sphere_triangles +from glue_ar.common.shapes import cone_points, cone_triangles, \ + cylinder_points, cylinder_triangles, \ + rectangular_prism_points, rectangular_prism_triangulation, \ + sphere_points, sphere_triangles class TestShapes: @@ -83,13 +86,12 @@ def test_sphere_points(self): assert points == expected - @pytest.mark.parametrize("theta_resolution,phi_resolution", product((5, 8, 10, 12, 15, 20), repeat=2)) def test_sphere_points_count(self, theta_resolution, phi_resolution): points = sphere_points(center=(0, 0, 0,), radius=1, theta_resolution=theta_resolution, phi_resolution=phi_resolution) - assert len(points) == 2 + (theta_resolution - 2) * phi_resolution + assert len(points) == 2 + (theta_resolution - 2) * phi_resolution @pytest.mark.parametrize("theta_resolution,phi_resolution", product((5, 8, 10, 12, 15, 20), repeat=2)) def test_sphere_triangles_count(self, theta_resolution, phi_resolution): @@ -97,7 +99,6 @@ def test_sphere_triangles_count(self, theta_resolution, phi_resolution): phi_resolution=phi_resolution) assert len(triangles) == 2 * phi_resolution * (theta_resolution - 2) - def test_cylinder_points(self): center = (-1, 2, -5) radius = 3 @@ -160,6 +161,3 @@ def test_cone_triangles_count(self, theta_resolution, start_index): triangles = cone_triangles(theta_resolution=theta_resolution, start_index=start_index) assert len(triangles) == 2 * theta_resolution - 2 - - - From 8cf7565cdb715f4d6381f408f3e4e2a550e9c669 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 10:38:19 -0400 Subject: [PATCH 08/10] Update self-typed annotations for Python < 3.10 --- glue_ar/common/gltf_builder.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/glue_ar/common/gltf_builder.py b/glue_ar/common/gltf_builder.py index a86c6e9..1661ea4 100644 --- a/glue_ar/common/gltf_builder.py +++ b/glue_ar/common/gltf_builder.py @@ -1,10 +1,11 @@ +from __future__ import annotations + from gltflib import Accessor, AccessorType, AlphaMode, Asset, Attributes, Buffer, \ BufferTarget, BufferView, ComponentType, GLTFModel, \ Material, Mesh, Node, PBRMetallicRoughness, Primitive, PrimitiveMode, Scene from gltflib.gltf import GLTF from gltflib.gltf_resource import FileResource - -from typing import Iterable, List, Optional, Self, Union +from typing import Iterable, List, Optional, Union class GLTFBuilder: @@ -22,7 +23,7 @@ def add_material(self, opacity: float = 1, roughness_factor: float = 1, metallic_factor: float = 0, - alpha_mode: AlphaMode = AlphaMode.BLEND) -> Self: + alpha_mode: AlphaMode = AlphaMode.BLEND) -> GLTFBuilder: if any(c > 1 for c in color): color = [c / 256 for c in color[:3]] self.materials.append( @@ -41,7 +42,7 @@ def add_mesh(self, position_accessor: int, indices_accessor: Optional[int] = None, material: Optional[int] = None, - mode: PrimitiveMode = PrimitiveMode.TRIANGLES) -> Self: + mode: PrimitiveMode = PrimitiveMode.TRIANGLES) -> GLTFBuilder: primitive_kwargs = { "attributes": Attributes(POSITION=position_accessor), @@ -60,7 +61,7 @@ def add_mesh(self, def add_buffer(self, byte_length: int, - uri: str) -> Self: + uri: str) -> GLTFBuilder: self.buffers.append( Buffer( byteLength=byte_length, @@ -73,7 +74,7 @@ def add_buffer_view(self, buffer: int, byte_length: int, byte_offset: int, - target: BufferTarget) -> Self: + target: BufferTarget) -> GLTFBuilder: self.buffer_views.append( BufferView( buffer=buffer, @@ -90,7 +91,7 @@ def add_accessor(self, count: int, type: AccessorType, mins: List[Union[int, float]], - maxes: List[Union[int, float]]) -> Self: + maxes: List[Union[int, float]]) -> GLTFBuilder: self.accessors.append( Accessor( bufferView=buffer_view, @@ -105,7 +106,7 @@ def add_accessor(self, def add_file_resource(self, filename: str, - data: bytearray) -> Self: + data: bytearray) -> GLTFBuilder: self.file_resources.append( FileResource( filename, From 4c4d7b5ffee061479d3b95ee7face4803463ff7a Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 11:02:19 -0400 Subject: [PATCH 09/10] We can't unpack in an index expression for Python < 3.11. --- glue_ar/common/voxels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/glue_ar/common/voxels.py b/glue_ar/common/voxels.py index 2bb7725..493c929 100644 --- a/glue_ar/common/voxels.py +++ b/glue_ar/common/voxels.py @@ -93,7 +93,7 @@ def add_voxel_layers_gltf(builder: GLTFBuilder, color_components = hex_to_components(color) for indices in nonempty_indices: - value = data[*indices] + value = data[tuple(indices)] adjusted_opacity = min(max(layer_state.alpha * opacity_factor * (value - isomin) / isorange, 0), 1) indices_tpl = tuple(indices) if indices_tpl in occupied_voxels: @@ -208,7 +208,7 @@ def add_voxel_layers_usd(builder: USDBuilder, color_components = hex_to_components(color) for indices in nonempty_indices: - value = data[*indices] + value = data[tuple(indices)] adjusted_opacity = min(max(layer_state.alpha * opacity_factor * (value - isomin) / isorange, 0), 1) indices_tpl = tuple(indices) if indices_tpl in occupied_voxels: From dbf592f74f43d38247eb0de1692c9101a2a703fd Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Sat, 10 Aug 2024 15:00:41 -0400 Subject: [PATCH 10/10] Drop Python 3.8 support to work with PyMCubes. --- .github/workflows/ci-workflows.yml | 3 --- setup.py | 2 +- tox.ini | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-workflows.yml b/.github/workflows/ci-workflows.yml index 44c0169..053a29d 100644 --- a/.github/workflows/ci-workflows.yml +++ b/.github/workflows/ci-workflows.yml @@ -32,19 +32,16 @@ jobs: - libegl1-mesa - libhdf5-dev envs: | - - linux: py38-test - linux: py39-test - linux: py310-test - linux: py311-test - linux: py312-test - - macos: py38-test - macos: py39-test - macos: py310-test - macos: py311-test - macos: py312-test - - windows: py38-test - windows: py39-test - windows: py310-test - windows: py311-test diff --git a/setup.py b/setup.py index c5e16ad..16608fb 100644 --- a/setup.py +++ b/setup.py @@ -731,7 +731,7 @@ def data_files(root_directory): setup_args = dict( name=name, cmdclass=cmdclass, - python_requires=">=3.8", + python_requires=">=3.9", zip_safe=False, packages=find_packages(name, exclude=["js"]), package_data={ diff --git a/tox.ini b/tox.ini index 258a376..c0fec35 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,39,310,311,312}-test +envlist = py{39,310,311,312}-test [testenv] passenv =