diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 41cb3b3..9caeed4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python_version: ["3.6", "3.7", "3.8"] + python_version: ["3.6", "3.9"] os: [ubuntu-latest, macos-latest, windows-latest] steps: @@ -29,11 +29,16 @@ jobs: architecture: 'x64' - name: Install dependencies - run: pip install .[test] + run: | + python -m pip install -U pip codecov + pip install -e .[test] - name: Run test run: | python setup.py --version - python setup.py build_py - python setup.py sdist - python -m pytest + python -m build . + pytest -vv --cov jupyter_packaging --cov-branch --cov-report term-missing:skip-covered + + - name: Coverage + run: | + codecov diff --git a/README.md b/README.md index f287158..4c01a7d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# jupyter-packaging +# Jupyter Packaging -Tools to help build and install Jupyter Python packages +Tools to help build and install Jupyter Python packages that require a pre-build step that may include JavaScript build steps. ## Install @@ -8,65 +8,106 @@ Tools to help build and install Jupyter Python packages ## Usage -There are two ways to use `jupyter-packaging` in another package. +There are three ways to use `jupyter-packaging` in another package. -The first to use a `pyproject.toml` file as outlined in [pep-518](https://www.python.org/dev/peps/pep-0518/). +### As a Build Requirement + +Use a `pyproject.toml` file as outlined in [pep-518](https://www.python.org/dev/peps/pep-0518/). An example: -``` +```toml [build-system] -requires = ["jupyter_packaging~=0.6.0", "jupyterlab~=2.0", "setuptools>=40.8.0", "wheel"] +requires = ["jupyter_packaging~=0.8.0"] build-backend = "setuptools.build_meta" ``` -The second method is to vendor `setupbase.py` locally alongside `setup.py` and import the helpers from `setupbase`. +Below is an example `setup.py` using the above config. +It assumes the rest of your metadata is in [`setup.cfg`](https://setuptools.readthedocs.io/en/latest/userguide/declarative_config.html). +We wrap the import in a try/catch to allow the file to be run without `jupyter_packaging` +so that `python setup.py` can be run directly when not building. + +```py +from setuptools import setup + +try: + from jupyter_packaging import wrap_installers, npm_builder + builder = npm_builder() + cmdclass = wrap_installers(pre_develop=builder, pre_dist=builder) +except ImportError: + cmdclass = {} + +setup(cmdclass=cmdclass)) +``` + +### As a Build Backend -Below is an example `setup.py` that uses the `pyproject.toml` approach: +Use the `jupyter_packaging` build backend. +The pre-build command is specified as metadata in `pyproject.toml`: + +```toml +[build-system] +requires = ["jupyter_packaging~=0.8.0"] +build-backend = "jupyter_packaging.build_api" + +[tool.jupyter-packaging.builder] +func = "jupyter_packaging.npm_builder" + +[tool.jupyter-packaging.build-args] +build_cmd = "build:src" +``` + +The corresponding `setup.py` would be greatly simplified: ```py from setuptools import setup -from jupyter_packaging import create_cmdclass, install_npm - - -cmdclass = create_cmdclass(['js']) -cmdclass['js'] = install_npm() - -setup_args = dict( - name = 'PROJECT_NAME', - description = 'PROJECT_DESCRIPTION', - long_description = 'PROJECT_LONG_DESCRIPTION', - version = 'PROJECT_VERSION', - author = 'Jupyter Development Team', - author_email = 'jupyter@googlegroups.com', - url = 'http://jupyter.org', - license = 'BSD', - platforms = "Linux, Mac OS X, Windows", - keywords = ['ipython', 'jupyter'], - classifiers = [ - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - ], - cmdclass = cmdclass, - install_requires = [ - 'notebook>=4.3.0', - ] -) - -if __name__ == '__main__': - setup(**setup_args) +setup() ``` -## Development Install +The `tool.jupyter-packaging.builder` section expects a `func` value that points to an importable +module and a function with dot separators. If not given, no pre-build function will run. + +The optional `tool.jupyter-packaging.build-args` sections accepts a dict of keyword arguments to +give to the pre-build command. + +The build backend does not handle the `develop` command (`pip install -e .`). +If desired, you can wrap just that command: +```py +import setuptools + +try: + from jupyter_packaging import wrap_installers, npm_builder + builder = npm_builder(build_cmd="build:dev") + cmdclass = wrap_installers(pre_develop=builder) +except ImportError: + cmdclass = {} + +setup(cmdclass=cmdclass)) ``` + +### As a Vendored File + +Vendor `setupbase.py` locally alongside `setup.py` and import the module directly. + +```py +import setuptools +from setupbase import wrap_installers, npm_builder +builder = npm_builder +cmdclass = wrap_installers(post_develop=builder, pre_dist=builder) +setup(cmdclass=cmdclass) +``` + +## Usage Notes + +- We recommend using `include_package_data=True` and `MANIFEST.in` to control the assets included in the [package](https://setuptools.readthedocs.io/en/latest/userguide/datafiles.html). +- Tools like [`check-manifest`](https://github.com/mgedmin/check-manifest) or [`manifix`](https://github.com/vidartf/manifix) can be used to ensure the desired assets are included. +- Simple uses of `data_files` can be handled in `setup.cfg` or in `setup.py`. If recursive directories are needed use `get_data_files()` from this package. +- Unfortunately `data_files` are not supported in `develop` mode (a limitation of `setuptools`). You can work around it by doing a full install (`pip install .`) before the develop install (`pip install -e .`), or by adding a script to push the data files to `sys.base_prefix`. + + +## Development Install + +```bash git clone https://github.com/jupyter/jupyter-packaging.git cd jupyter-packaging pip install -e . @@ -74,8 +115,10 @@ pip install -e . You can test changes locally by creating a `pyproject.toml` with the following, replacing the local path to the git checkout: -``` +```toml [build-system] -requires = ["jupyter_packaging@file://", "setuptools>=40.8.0", "wheel"] +requires = ["jupyter_packaging@file://"] build-backend = "setuptools.build_meta" -``` \ No newline at end of file +``` + +Note: you need to run `pip cache remove jupyter_packaging` any time changes are made to prevent `pip` from using a cached version of the source. diff --git a/jupyter_packaging/__main__.py b/jupyter_packaging/__main__.py index 90d123d..548352d 100644 --- a/jupyter_packaging/__main__.py +++ b/jupyter_packaging/__main__.py @@ -36,5 +36,5 @@ def main(args=None): shutil.copy(source, destination) -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover main() diff --git a/jupyter_packaging/build_api.py b/jupyter_packaging/build_api.py new file mode 100644 index 0000000..d04a9c0 --- /dev/null +++ b/jupyter_packaging/build_api.py @@ -0,0 +1,65 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import functools +import importlib +from pathlib import Path +import os +import sys + +from setuptools.build_meta import ( + get_requires_for_build_wheel, + get_requires_for_build_sdist, + prepare_metadata_for_build_wheel, + build_sdist as orig_build_sdist, + build_wheel as orig_build_wheel +) +import tomlkit + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + """Build a wheel with an optional pre-build step.""" + builder = _get_build_func() + if builder: + builder() + return orig_build_wheel(wheel_directory, config_settings=config_settings, metadata_directory=metadata_directory) + + +def build_sdist(sdist_directory, config_settings=None): + """Build an sdist with an optional pre-build step.""" + builder = _get_build_func() + if builder: + builder() + return orig_build_sdist(sdist_directory, config_settings=config_settings) + + +def _get_build_func(): + pyproject = Path('pyproject.toml') + if not pyproject.exists(): + return + data = tomlkit.loads(pyproject.read_text(encoding='utf-8')) + if 'tool' not in data: + return + if 'jupyter-packaging' not in data['tool']: + return + if 'builder' not in data['tool']['jupyter-packaging']: + return + section = data['tool']['jupyter-packaging'] + if 'func' not in section['builder']: + raise ValueError('Missing `func` specifier for builder') + + func_data = section['builder']['func'] + mod_name, _, func_name = func_data.rpartition('.') + + # If the module fails to import, try importing as a local script + try: + mod = importlib.import_module(mod_name) + except ImportError: + try: + sys.path.insert(0, os.getcwd()) + mod = importlib.import_module(mod_name) + finally: + sys.path.pop(0) + + func = getattr(mod, func_name) + kwargs = section.get('build-args', {}) + return functools.partial(func, **kwargs) diff --git a/jupyter_packaging/setupbase.py b/jupyter_packaging/setupbase.py index c552200..4d6a62a 100644 --- a/jupyter_packaging/setupbase.py +++ b/jupyter_packaging/setupbase.py @@ -18,9 +18,15 @@ import pipes import re import shlex +from shutil import which import subprocess import sys +try: + from deprecation import deprecated +except ImportError: + # shim deprecated to allow setuptools to find the version string in this file + deprecated = lambda *args, **kwargs: lambda *args, **kwargs: None # BEFORE importing distutils, remove MANIFEST. distutils doesn't properly # update it when the contents of directories change. @@ -29,6 +35,7 @@ from setuptools import Command from setuptools.command.build_py import build_py +from setuptools.config import StaticModule # Note: distutils must be imported after setuptools from distutils import log @@ -39,10 +46,10 @@ try: from wheel.bdist_wheel import bdist_wheel -except ImportError: +except ImportError: # pragma: no cover bdist_wheel = None -if sys.platform == 'win32': +if sys.platform == 'win32': # pragma: no cover from subprocess import list2cmdline else: def list2cmdline(cmd_list): @@ -66,159 +73,177 @@ def list2cmdline(cmd_list): # --------------------------------------------------------------------------- -# Public Functions +# Core 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 wrap_installers(pre_develop=None, pre_dist=None, post_develop=None, post_dist=None): + """Make a setuptools cmdclass that calls a prebuild function before installing. + Parameters + ---------- + pre_develop: function + The function to call prior to the develop command. + pre_dist: function + The function to call prior to the sdist and wheel commands + post_develop: function + The function to call after the develop command. + post_dist: function + The function to call after the sdist and wheel commands. -def ensure_python(specs): - """Given a list of range specifiers for python, ensure compatibility. + Notes + ----- + For any function given, creates a new `setuptools` command that can be run separately, + e.g. `python setup.py pre_develop`. + + Returns + ------- + A cmdclass dictionary for setup args. """ - import warnings - warnings.warn( - 'Deprecated, please use python_requires in the setup function directly', - category=DeprecationWarning - ) - if not isinstance(specs, (list, tuple)): - specs = [specs] - v = sys.version_info - part = '%s.%s' % (v.major, v.minor) - for spec in specs: - if part == spec: - return - try: - if eval(part + spec): - return - except SyntaxError: - pass - raise ValueError('Python version %s unsupported' % part) + cmdclass = {} + def _make_command(name, func): + class _Wrapped(BaseCommand): + def run(self): + func() -def find_packages(top): - """ - Find all of the packages. - """ - import warnings - warnings.warn( - 'Deprecated, please use setuptools.find_packages', - category=DeprecationWarning - ) - from setuptools import find_packages as fp - return fp(top) + _Wrapped.__name__ = name + cmdclass[name] = _Wrapped + for name in ['pre_develop', 'post_develop', 'pre_dist', 'post_dist']: + if locals()[name]: + _make_command(name, locals()[name]) -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() + def _make_wrapper(klass, pre_build, post_build): + class _Wrapped(klass): + def run(self): + if pre_build: + self.run_command(pre_build.__name__) + klass.run(self) + if post_build: + self.run_command(post_build.__name__) + cmdclass[klass.__name__] = _Wrapped + if pre_develop or post_develop: + _make_wrapper(develop, pre_develop, post_develop) -class bdist_egg_disabled(bdist_egg): - """Disabled version of bdist_egg + if pre_dist or post_dist: + _make_wrapper(sdist, pre_dist, post_dist) + _make_wrapper(bdist_wheel, pre_dist, post_dist) - 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.") + return cmdclass -def create_cmdclass(prerelease_cmd=None, package_data_spec=None, - data_files_spec=None, exclude=None): - """Create a command class with the given optional prerelease class. +def npm_builder(path=None, build_dir=None, source_dir=None, build_cmd='build', + force=False, npm=None): + """Create a build function for managing an npm installation. + + Note: The function is a no-op if the `--skip-npm` cli flag is used. 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. - exclude: function - A function which takes a string filename and returns True if the - file should be excluded from package data and data files, False otherwise. + path: str, optional + The base path of the node package. Defaults to the current directory. + 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]. - Notes - ----- - We use specs so that we can find the files *after* the build - command has run. + Returns + ------- + A build function to use with `wrap_installers` + """ + def builder(): + if skip_npm: + log.info('Skipping npm-installation') + return + node_package = path or os.path.abspath(os.getcwd()) + node_modules = pjoin(node_package, 'node_modules') + is_yarn = os.path.exists(pjoin(node_package, 'yarn.lock')) - 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/**'])` + npm_cmd = npm - 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') + if npm is None: + if is_yarn: + npm_cmd = ['yarn'] + else: + npm_cmd = ['npm'] - wrapper = functools.partial(_wrap_command, wrapped) - handle_files = _get_file_handler(package_data_spec, data_files_spec, exclude) - develop_handler = _get_develop_handler() + if not which(npm_cmd[0]): + log.error("`{0}` unavailable. If you're running this command " + "using sudo, make sure `{0}` is available to sudo" + .format(npm_cmd[0])) + return - if 'bdist_egg' in sys.argv: - egg = wrapper(bdist_egg, strict=True) - else: - egg = bdist_egg_disabled + if build_dir and source_dir and not force: + should_build = is_stale(build_dir, source_dir) + else: + should_build = True - is_repo = os.path.exists('.git') + if should_build: + log.info('Installing build dependencies with npm. This may ' + 'take a while...') + run(npm_cmd + ['install'], cwd=node_package) + if build_cmd: + run(npm_cmd + ['run', build_cmd], cwd=node_package) + return builder - cmdclass = dict( - build_py=wrapper(build_py, strict=is_repo), - bdist_egg=egg, - sdist=wrapper(sdist, strict=True), - handle_files=handle_files, - ) +# --------------------------------------------------------------------------- +# Utility Functions +# --------------------------------------------------------------------------- - if bdist_wheel: - cmdclass['bdist_wheel'] = wrapper(bdist_wheel, strict=True) +def get_data_files(data_specs, *, top=None, exclude=None): + """Expand data file specs into valid data files metadata. - cmdclass['develop'] = wrapper(develop_handler, strict=True) - return cmdclass + Parameters + ---------- + data_files_spec: list + 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. + top: str, optional + The top directory + exclude: func, optional + Function used to test whether to exclude a file + Returns + ------- + A valid list of data_files items. + """ + return _get_data_files(data_specs, None, top=top, exclude=exclude) -def command_for_func(func): - """Create a command that calls the given function.""" - class FuncCommand(BaseCommand): +def get_version(fpath, name='__version__'): + """Get the version of the package from the given file by extracting the given `name`. + """ + # Try to get it from a static import first + try: - def run(self): - func() - update_package_data(self.distribution) + module = StaticModule(fpath.replace(os.sep, '.').replace('.py', '')) + return getattr(module, name) + except Exception as e: + pass - return FuncCommand + path = os.path.realpath(fpath) + version_ns = {} + with io.open(path, encoding="utf8") as f: + exec(f.read(), {}, version_ns) + return version_ns[name] def run(cmd, **kwargs): """Echo a command before running it.""" log.info('> ' + list2cmdline(cmd)) kwargs.setdefault('shell', os.name == 'nt') - if not isinstance(cmd, (list, tuple)) and os.name != 'nt': + if not isinstance(cmd, (list, tuple)): cmd = shlex.split(cmd) cmd_path = which(cmd[0]) if not cmd_path: - sys.exit("Aborting. Could not find cmd (%s) in path. " + raise ValueError("Aborting. Could not find cmd (%s) in path. " "If command is not expected to be in user's path, " "use an absolute path." % cmd[0]) cmd[0] = cmd_path @@ -254,10 +279,7 @@ def get_outputs(self): def combine_commands(*commands): """Return a Command that combines several commands.""" - - class CombinedCommand(Command): - user_options = [] - + class CombinedCommand(BaseCommand): def initialize_options(self): self.commands = [] for C in commands: @@ -322,65 +344,27 @@ def mtime(path): 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 current directory. - 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]. - """ +def skip_if_exists(paths, CommandClass): + """Skip a command if list of paths exists.""" + def should_skip(): + return all(Path(path).exists() for path in paths) + class SkipIfExistCommand(Command): + def initialize_options(self): + if not should_skip(): + self.command = CommandClass(self.distribution) + self.command.initialize_options() + else: + self.command = None - class NPM(BaseCommand): - description = 'install package.json dependencies using npm' + def finalize_options(self): + if self.command is not None: + self.command.finalize_options() def run(self): - if skip_npm: - log.info('Skipping npm-installation') - return - node_package = path or os.path.abspath(os.getcwd()) - 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 available to sudo" - .format(npm_cmd[0])) - return - - if build_dir and source_dir and not force: - should_build = is_stale(build_dir, source_dir) - else: - should_build = True - - if should_build: - log.info('Installing build dependencies with npm. This may ' - 'take a while...') - run(npm_cmd + ['install'], cwd=node_package) - if build_cmd: - run(npm_cmd + ['run', build_cmd], cwd=node_package) + if self.command is not None: + self.command.run() - return NPM + return SkipIfExistCommand def ensure_targets(targets): @@ -390,7 +374,6 @@ def ensure_targets(targets): Note: The check is skipped if the `--skip-npm` flag is used. """ - class TargetsCheck(BaseCommand): def run(self): if skip_npm: @@ -403,88 +386,183 @@ def run(self): return TargetsCheck -def skip_if_exists(paths, CommandClass): - """Skip a command if list of paths exists.""" - def should_skip(): - return all(Path(path).exists() for path in paths) - class SkipIfExistCommand(Command): - def initialize_options(self): - if not should_skip(): - self.command = CommandClass(self.distribution) - self.command.initialize_options() - else: - self.command = None +# --------------------------------------------------------------------------- +# Deprecated Functions +# --------------------------------------------------------------------------- - def finalize_options(self): - if self.command is not None: - self.command.finalize_options() +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `BaseCommand` directly instead") +def command_for_func(func): + """Create a command that calls the given function.""" + class FuncCommand(BaseCommand): def run(self): - if self.command is not None: - self.command.run() + func() + update_package_data(self.distribution) - return SkipIfExistCommand + return FuncCommand + + +@deprecated(deprecated_in="0.7", removed_in="1.0", current_version=__version__, + details="Use `setuptools` `python_requires` instead") +def ensure_python(specs): + """Given a list of range specifiers for python, ensure compatibility. + """ + if not isinstance(specs, (list, tuple)): + specs = [specs] + v = sys.version_info + part = '%s.%s' % (v.major, v.minor) + for spec in specs: + if part == spec: + return + try: + if eval(part + spec): + return + except SyntaxError: + pass + raise ValueError('Python version %s unsupported' % part) + + +@deprecated(deprecated_in="0.7", removed_in="1.0", current_version=__version__, + details="Use `setuptools.find_packages` instead") +def find_packages(top): + """ + Find all of the packages. + """ + from setuptools import find_packages as fp + return fp(top) + + +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `use_package_data=True` and `MANIFEST.in` instead") +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() + + +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Not needed") +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.") -# `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. +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="""" +Use `wrap_installers` to handle prebuild steps in cmdclass. +Use `get_data_files` to handle data files. +Use `include_package_data=True` and `MANIFEST.in` for package data. +""") +def create_cmdclass(prerelease_cmd=None, package_data_spec=None, + data_files_spec=None, exclude=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. + exclude: function + A function which takes a string filename and returns True if the + file should be excluded from package data and data files, False otherwise. + + 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') - # 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] + wrapper = functools.partial(_wrap_command, wrapped) + handle_files = _get_file_handler(package_data_spec, data_files_spec, exclude) + develop_handler = _get_develop_handler() + + if 'bdist_egg' in sys.argv: + egg = wrapper(bdist_egg, strict=True) 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 + egg = bdist_egg_disabled + is_repo = os.path.exists('.git') + + 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_handler, strict=True) + return cmdclass + + +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `npm_builder` and `wrap_installers`") +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 current directory. + 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]. + """ + npm_builder(path=path, build_dir=build_dir, source_dir=source_dir, build_cmd=build_cmd, force=force, npm=npm) + + class NPM(BaseCommand): + description = 'install package.json dependencies using npm' + + def run(self): + builder() + + return NPM # --------------------------------------------------------------------------- # Private Functions # --------------------------------------------------------------------------- - +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `npm_builder` and `wrap_installers`") def _wrap_command(cmds, cls, strict=True): """Wrap a setup command @@ -514,6 +592,8 @@ def run(self): return WrappedCommand +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `npm_builder` and `wrap_installers`") def _get_file_handler(package_data_spec, data_files_spec, exclude=None): """Get a package_data and data_files handler command. """ @@ -536,6 +616,8 @@ def run(self): return FileHandler +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `npm_builder` and `wrap_installers`") def _get_develop_handler(): """Get a handler for the develop command""" class _develop(develop): @@ -657,6 +739,8 @@ def _get_files(file_patterns, top=None): return list(files) +@deprecated(deprecated_in="0.8", removed_in="1.0", current_version=__version__, + details="Use `npm_builder` and `wrap_installers`") def _get_package_data(root, file_patterns=None): """Expand file patterns to a list of `package_data` paths. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2f21011 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index e3a92c1..e94b415 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,41 @@ +[metadata] +name = jupyter_packaging +version = attr: jupyter_packaging.setupbase.__version__ +description = Jupyter Packaging Utilities. +long_description = file: README.md +long_description_content_type = text/markdown +license = BSD 3-Clause License +author = Jupyter Development Team +author_email = jupyter@googlegroups.com +url = http://jupyter.org +platforms = Linux, Mac OS X, Windows +keywords = ipython, jupyter, packaging +classifiers = + Intended Audience :: Developers + Intended Audience :: System Administrators + Intended Audience :: Science/Research + License :: OSI Approved :: BSD License + Programming Language :: Python + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + +[options] +zip_safe = False +include_package_data = True +packages = find: +python_requires = >=3.6 +install_requires = + packaging + tomlkit + setuptools>=46.4.0 + wheel + deprecation + +[options.extras_require] +test = build; coverage; pytest; pytest-cov; pytest-mock + [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index cf9aa92..6931d83 100644 --- a/setup.py +++ b/setup.py @@ -4,51 +4,5 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from __future__ import print_function -import os from setuptools import setup -from jupyter_packaging.setupbase import ( - create_cmdclass, find_packages, __version__ -) - -here = os.path.dirname(os.path.abspath(__file__)) - -setup_args = dict( - name = 'jupyter-packaging', - version = __version__, - packages = find_packages(here), - description = "Jupyter Packaging Utilities", - long_description= """ - This package contains utilities for making Python packages with - and without accompanying JavaScript packages. - """, - long_description_content_type='text/x-rst', - author = 'Jupyter Development Team', - author_email = 'jupyter@googlegroups.com', - url = 'http://jupyter.org', - license = 'BSD', - platforms = "Linux, Mac OS X, Windows", - keywords = ['Jupyter', 'Packaging'], - cmdclass = create_cmdclass(), - python_requires = '>=3.6', - install_requires = "packaging", - extras_require = { - 'test': [ - 'pytest' - ], - }, - classifiers = [ - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - ], -) - -if __name__ == '__main__': - setup(**setup_args) +setup() diff --git a/tests/conftest.py b/tests/conftest.py index a7454cb..42b1391 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,16 @@ import os import pathlib from pytest import fixture +from subprocess import run HERE = pathlib.Path(__file__).resolve() +@fixture(scope="session", autouse=True) +def clear_pip_cache(): + run(['python', '-m', 'pip', 'cache', 'remove', 'jupyter_packaging']) + + @fixture def pyproject_toml(): """A fixture that enables other fixtures to build mock packages @@ -13,45 +19,56 @@ def pyproject_toml(): root_path = HERE.joinpath("../..").resolve() return """ [build-system] -requires = ["jupyter_packaging@file://%s", "setuptools>=40.8.0", "wheel"] +requires = ["jupyter_packaging@file://%s"] build-backend = "setuptools.build_meta" """ % str(root_path).replace(os.sep, '/') - -setup = lambda name="jupyter_packaging_test_foo", data_files_spec=None, **kwargs: """ -from jupyter_packaging import create_cmdclass +setup_cfg_maker = lambda name="jupyter_packaging_test_foo": """ +[metadata] +name = {name} +version = 0.1 +author = Jupyter Development Team +author_email = jupyter@googlegroups.com +url = http://jupyter.org +description="foo package", +long_description="long_description", +long_description_content_type="text/markdown", + +[options] +zip_safe = False +include_package_data = True +packages = find: +python_requires = >=3.6 +""".format(name=name) + +setup_maker = lambda name="jupyter_packaging_test_foo", data_files_spec=None, **kwargs: """ +from jupyter_packaging import get_data_files import setuptools import os - -name = "{name}" - - def exclude(filename): return os.path.basename(filename) == "exclude.py" -cmdclass = create_cmdclass( - data_files_spec={data_files_spec}, - exclude=exclude -) +data_files=get_data_files({data_files_spec}, exclude=exclude) -setup_args = dict( +setuptools.setup(data_files=data_files, {setup_args}) +""".format( name=name, - version="0.1", - url="foo url", - author="foo author", - description="foo package", - long_description="long_description", - long_description_content_type="text/markdown", - cmdclass= cmdclass, - zip_safe=False, - include_package_data=True, - {setup_args} + data_files_spec=data_files_spec, + setup_args="".join(['{}={},\n\t'.format(key, str(val)) for key, val in kwargs.items()]) ) +setup_maker_deprecated = lambda name="jupyter_packaging_test_foo", data_files_spec=None, **kwargs: """ +from jupyter_packaging import create_cmdclass +import setuptools +import os + +def exclude(filename): + return os.path.basename(filename) == "exclude.py" + +cmdclass = create_cmdclass(data_files_spec={data_files_spec}, exclude=exclude) -if __name__ == "__main__": - setuptools.setup(**setup_args) +setuptools.setup(cmdclass=cmdclass, {setup_args}) """.format( name=name, data_files_spec=data_files_spec, @@ -59,12 +76,8 @@ def exclude(filename): ) -@fixture -def make_package(tmp_path, pyproject_toml): - """A callable fixture that creates a mock python package - in tmp_path and returns the package directory - """ - def stuff( +def make_package_base(tmp_path, pyproject_toml, setup_func=setup_maker): + def do_stuff( name="jupyter_packaging_test_foo", data_files=None, data_files_spec=None, @@ -94,7 +107,7 @@ def stuff( # 1. Add a setup.py setuppy = pkg.joinpath("setup.py") # Pass the data_file spec to the setup.py - setup_content = setup( + setup_content = setup_func( name=name, data_files_spec=data_files_spec, **setup_args @@ -102,19 +115,39 @@ def stuff( setuppy.write_text(setup_content) # 2. Add pyproject.toml to package. - with open(pkg.joinpath('pyproject.toml'), 'w') as fid: - fid.write(pyproject_toml) + pkg.joinpath('pyproject.toml').write_text(pyproject_toml) + + # 3. Add setup.cfg to package + pkg.joinpath('setup.cfg').write_text(setup_cfg_maker(name=name)) # 3. Add datafiles content. + manifest = pkg / 'MANIFEST.in' if data_files: for datafile_path in data_files: data_file = pkg.joinpath(datafile_path) data_dir = data_file.parent data_dir.mkdir(parents=True, exist_ok=True) data_file.write_text("hello, world!") + text = manifest.read_text() + manifest.write_text(text + f'\ninclude {datafile_path}') return pkg + return do_stuff + + +@fixture +def make_package(tmp_path, pyproject_toml): + """A callable fixture that creates a mock python package + in tmp_path and returns the package directory + """ + return make_package_base(tmp_path, pyproject_toml) + - return stuff +@fixture +def make_package_deprecated(tmp_path, pyproject_toml): + """A callable fixture that creates a mock python package + in tmp_path and returns the package directory + """ + return make_package_base(tmp_path, pyproject_toml, setup_func=setup_maker_deprecated) @fixture diff --git a/tests/test_build_api.py b/tests/test_build_api.py new file mode 100644 index 0000000..b9a4739 --- /dev/null +++ b/tests/test_build_api.py @@ -0,0 +1,103 @@ +import os +from subprocess import check_call +from unittest.mock import patch, call + +import pytest + +from jupyter_packaging.build_api import build_wheel, build_sdist + + +TOML_CONTENT = """ +[tool.jupyter-packaging.builder] +func = "foo.main" + +[tool.jupyter-packaging.build-args] +fizz = "buzz" +""" + +FOO_CONTENT = r""" +from pathlib import Path +def main(fizz=None): + Path('foo.txt').write_text(f'fizz={fizz}', encoding='utf-8') +""" + +BAD_CONTENT = """ +[tool.jupyter-packaging.builder] +bar = "foo.main" +""" + + +def test_build_wheel_no_toml(tmp_path): + os.chdir(tmp_path) + orig_wheel = patch('jupyter_packaging.build_api.orig_build_wheel') + build_wheel(tmp_path) + orig_wheel.assert_called_with(tmp_path, config_settings=None, metadata_directory=None) + + +def test_build_wheel(tmp_path, mocker): + os.chdir(tmp_path) + tmp_path.joinpath('foo.py').write_text(FOO_CONTENT) + tmp_path.joinpath('pyproject.toml').write_text(TOML_CONTENT, encoding='utf-8') + orig_wheel = mocker.patch('jupyter_packaging.build_api.orig_build_wheel') + build_wheel(tmp_path) + orig_wheel.assert_called_with(tmp_path, config_settings=None, metadata_directory=None) + data = tmp_path.joinpath('foo.txt').read_text(encoding='utf-8') + assert data == 'fizz=buzz' + + +def test_build_wheel_bad_toml(tmp_path, mocker): + os.chdir(tmp_path) + tmp_path.joinpath('foo.py').write_text(FOO_CONTENT) + tmp_path.joinpath('pyproject.toml').write_text(BAD_CONTENT, encoding='utf-8') + orig_wheel = mocker.patch('jupyter_packaging.build_api.orig_build_wheel') + with pytest.raises(ValueError): + build_wheel(tmp_path) + orig_wheel.assert_not_called() + + +def test_build_wheel_no_toml(tmp_path, mocker): + os.chdir(tmp_path) + orig_wheel = mocker.patch('jupyter_packaging.build_api.orig_build_wheel') + build_wheel(tmp_path) + orig_wheel.assert_called_with(tmp_path, config_settings=None, metadata_directory=None) + + +def test_build_sdist(tmp_path, mocker): + os.chdir(tmp_path) + tmp_path.joinpath('foo.py').write_text(FOO_CONTENT) + tmp_path.joinpath('pyproject.toml').write_text(TOML_CONTENT, encoding='utf-8') + orig_sdist = mocker.patch('jupyter_packaging.build_api.orig_build_sdist') + build_sdist(tmp_path) + orig_sdist.assert_called_with(tmp_path, config_settings=None) + data = tmp_path.joinpath('foo.txt').read_text(encoding='utf-8') + assert data == 'fizz=buzz' + + +def test_build_sdist_bad_toml(tmp_path, mocker): + os.chdir(tmp_path) + tmp_path.joinpath('foo.py').write_text(FOO_CONTENT) + tmp_path.joinpath('pyproject.toml').write_text(BAD_CONTENT, encoding='utf-8') + orig_sdist = mocker.patch('jupyter_packaging.build_api.orig_build_sdist') + with pytest.raises(ValueError): + build_sdist(tmp_path) + orig_sdist.assert_not_called() + + +def test_build_sdist_no_toml(tmp_path, mocker): + os.chdir(tmp_path) + orig_sdist = mocker.patch('jupyter_packaging.build_api.orig_build_sdist') + build_sdist(tmp_path) + orig_sdist.assert_called_with(tmp_path, config_settings=None) + + +def test_build_package(make_package): + package_dir = make_package() + pyproject = package_dir / "pyproject.toml" + text = pyproject.read_text(encoding='utf-8') + text = text.replace('setuptools.build_meta', 'jupyter_packaging.build_api') + text += TOML_CONTENT + pyproject.write_text(text, encoding='utf-8') + package_dir.joinpath('foo.py').write_text(FOO_CONTENT, encoding='utf-8') + check_call(['python', '-m', 'build'], cwd=package_dir) + data = package_dir.joinpath('foo.txt').read_text(encoding='utf-8') + assert data == 'fizz=buzz' diff --git a/tests/test_core_functions.py b/tests/test_core_functions.py new file mode 100644 index 0000000..e4df244 --- /dev/null +++ b/tests/test_core_functions.py @@ -0,0 +1,80 @@ +import os +from unittest.mock import patch, call + +import pytest +from setuptools.dist import Distribution + +from jupyter_packaging.setupbase import npm_builder, wrap_installers + + +def test_wrap_installers(): + called = False + def func(): + nonlocal called + called = True + + cmd_class = wrap_installers(pre_dist=func, pre_develop=func, + post_dist=func, post_develop=func) + + for name in ['pre_dist', 'pre_develop', 'post_dist', 'post_develop']: + cmd_class[name](Distribution()).run() + assert called + called = False + + +def test_npm_builder(mocker): + which = mocker.patch('jupyter_packaging.setupbase.which') + run = mocker.patch('jupyter_packaging.setupbase.run') + builder = npm_builder() + which.return_value = ['foo'] + builder() + cwd=os.getcwd() + run.assert_has_calls([ + call(['npm', 'install'], cwd=cwd), + call(['npm', 'run', 'build'], cwd=cwd) + ]) + + +def test_npm_build_skip(mocker): + which = mocker.patch('jupyter_packaging.setupbase.which') + run = mocker.patch('jupyter_packaging.setupbase.run') + mocker.patch('jupyter_packaging.setupbase.skip_npm', True) + builder = npm_builder() + which.return_value = ['foo'] + builder() + run.assert_not_called() + + +def test_npm_builder_yarn(tmp_path, mocker): + which = mocker.patch('jupyter_packaging.setupbase.which') + run = mocker.patch('jupyter_packaging.setupbase.run') + tmp_path.joinpath('yarn.lock').write_text('hello') + builder = npm_builder(path=tmp_path) + which.return_value = ['foo'] + builder() + run.assert_has_calls([ + call(['yarn', 'install'], cwd=tmp_path), + call(['yarn', 'run', 'build'], cwd=tmp_path) + ]) + + +def test_npm_builder_not_stale(tmp_path, mocker): + which = mocker.patch('jupyter_packaging.setupbase.which') + run = mocker.patch('jupyter_packaging.setupbase.run') + is_stale = mocker.patch('jupyter_packaging.setupbase.is_stale') + is_stale.return_value = False + builder = npm_builder(build_dir=tmp_path, source_dir=tmp_path) + which.return_value = ['foo'] + builder() + run.assert_not_called() + + +def test_npm_builder_no_npm(mocker): + which = mocker.patch('jupyter_packaging.setupbase.which') + run = mocker.patch('jupyter_packaging.setupbase.run') + is_stale = mocker.patch('jupyter_packaging.setupbase.is_stale') + is_stale.return_value = False + builder = npm_builder() + which.return_value = [] + builder() + run.assert_not_called() diff --git a/tests/test_datafiles_install.py b/tests/test_datafiles_install.py index 9b0cb2e..e83dc65 100644 --- a/tests/test_datafiles_install.py +++ b/tests/test_datafiles_install.py @@ -6,6 +6,8 @@ import pathlib import sys +from deprecation import fail_if_not_removed + data_files_combinations = [ @@ -21,7 +23,7 @@ # data file source ("share/test.txt",), # data file spec - ("jupyter-packaging-test/level1", "share", "**/*"), + ("jupyter-packaging-test/level1", "share", "**/[a-z]est.txt"), # data file target "jupyter-packaging-test/level1/test.txt" ), @@ -52,13 +54,15 @@ ] + +@fail_if_not_removed @pytest.mark.parametrize( 'source,spec,target', data_files_combinations ) -def test_develop(make_package, source,spec,target): +def test_develop(make_package_deprecated, source,spec,target): name = 'jupyter_packaging_test_foo' - package_dir = make_package(name=name, data_files=source, data_files_spec=[spec]) + package_dir = make_package_deprecated(name=name, data_files=source, data_files_spec=[spec]) target_path = pathlib.Path(sys.base_prefix).joinpath(target) if target_path.exists(): shutil.rmtree(str(target_path.parent)) diff --git a/tests/test_datafiles_paths.py b/tests/test_datafiles_paths.py index b5e4748..7fa723c 100644 --- a/tests/test_datafiles_paths.py +++ b/tests/test_datafiles_paths.py @@ -1,6 +1,6 @@ import os -from jupyter_packaging.setupbase import _get_data_files +from jupyter_packaging.setupbase import get_data_files def test_empty_relative_path(tmpdir): tmpdir.mkdir('sub1').join('a.json').write('') @@ -8,7 +8,7 @@ def test_empty_relative_path(tmpdir): spec = [ ('my/target', '', '**/*.json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target/sub1', ['sub1/a.json']), ('my/target/sub2', ['sub2/b.json']), @@ -21,7 +21,7 @@ def test_dot_relative_path(tmpdir): spec = [ ('my/target', '.', '**/*.json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target/sub1', ['sub1/a.json']), ('my/target/sub2', ['sub2/b.json']), @@ -32,9 +32,9 @@ def test_subdir_relative_path(tmpdir): tmpdir.mkdir('sub1').join('a.json').write('') tmpdir.mkdir('sub2').join('b.json').write('') spec = [ - ('my/target', 'sub1', '**/*.json') + ('my/target', 'sub1', '**/[a-z].json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target', ['sub1/a.json']), ] @@ -46,7 +46,7 @@ def test_root_absolute_path(tmpdir): spec = [ ('my/target', str(tmpdir), '**/*.json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target/sub1', ['sub1/a.json']), ('my/target/sub2', ['sub2/b.json']), @@ -59,7 +59,7 @@ def test_subdir_absolute_path(tmpdir): spec = [ ('my/target', str(tmpdir.join('sub1')), '**/*.json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target', ['sub1/a.json']), ] @@ -72,7 +72,7 @@ def test_absolute_trailing_slash(tmpdir): spec = [ ('my/target/', str(tmpdir) + '/', '**/*.*') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target/main/sub1', ['main/sub1/a.json']), ('my/target/main/sub2', ['main/sub2/b.json']), @@ -85,7 +85,7 @@ def test_relative_trailing_slash(tmpdir): spec = [ ('my/target/', 'main/', '**/*.json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target/sub1', ['main/sub1/a.json']), ('my/target/sub2', ['main/sub2/b.json']), @@ -99,7 +99,7 @@ def test_nested_source_dir(tmpdir): spec = [ ('my/target', 'main/sub1', 'a.json') ] - res = _get_data_files(spec, None, top=str(tmpdir)) + res = get_data_files(spec, top=str(tmpdir)) assert sorted(res) == [ ('my/target', ['main/sub1/a.json']), ] diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py new file mode 100644 index 0000000..37c5fec --- /dev/null +++ b/tests/test_deprecated.py @@ -0,0 +1,128 @@ + +import os +from unittest.mock import patch + +from deprecation import fail_if_not_removed +import pytest + +from setuptools.dist import Distribution + +import jupyter_packaging.setupbase as pkg + + +here = os.path.dirname(__file__) +root = os.path.join(here, os.pardir) + + +@fail_if_not_removed +def test_finds_itself(): + with pytest.warns(DeprecationWarning): + assert ['jupyter_packaging'] == pkg.find_packages(root) + + +def test_finds_subpackages(tmpdir): + a = tmpdir.mkdir('packageA') + sub_a1 = a.mkdir('sub1') + sub_a2 = a.mkdir('sub2') + b = tmpdir.mkdir('packageB') + sub_b1 = b.mkdir('sub1') + sub_b2 = b.mkdir('sub2') + for d in (a, sub_a1, sub_a2, b, sub_b1, sub_b2): + d.join('__init__.py').write('') + # using sets ensure order won't matter + expected = set([ + 'packageA', 'packageA.sub1', 'packageA.sub2', + 'packageB', 'packageB.sub1', 'packageB.sub2' + ]) + with pytest.warns(DeprecationWarning): + found = set(pkg.find_packages(str(tmpdir))) + assert expected == found + + +def test_finds_only_direct_subpackages(tmpdir): + a = tmpdir.mkdir('packageA') + sub_a1 = a.mkdir('sub1') + sub_a2 = a.mkdir('sub2') + # No __init__.py in packageA: + for d in (sub_a1, sub_a2): + d.join('__init__.py').write('') + + expected = [] + with pytest.warns(DeprecationWarning): + assert expected == pkg.find_packages(str(tmpdir)) + + +def test_ensure_python(): + pkg.ensure_python('>=3.6') + pkg.ensure_python(['>=3.6', '>=3.5']) + + with pytest.raises(ValueError): + pkg.ensure_python('<3.5') + + +def test_create_cmdclass(make_package_deprecated, mocker): + source = ("share/test.txt",) + spec = ("jupyter-packaging-test", "share", "**/*") + target = "jupyter-packaging-test/test.txt" + + pkg_path = make_package_deprecated(data_files=source, data_files_spec=spec) + os.chdir(pkg_path) + cmdclass = pkg.create_cmdclass( + package_data_spec=dict(foo="*.*"), + data_files_spec=[spec], + exclude=lambda x: False + ) + for name in ['build_py', 'handle_files', 'sdist', 'bdist_wheel']: + assert name in cmdclass + + dist = Distribution() + cmdclass['handle_files'](dist).run() + assert dist.data_files == [('jupyter-packaging-test', ['share/test.txt'])] + assert dist.package_data == {'foo': []} + + # Test installation of data_files in develop mode + dist = Distribution() + handler = cmdclass['handle_files'](dist) + develop = cmdclass['develop'](dist) + + def run_command(name): + cmdclass[name](dist).run() + + mocker.patch.object(pkg.develop, 'install_for_development') + develop.run_command = run_command + develop.install_for_development() + assert dist.data_files == [('jupyter-packaging-test', ['share/test.txt'])] + + +def test_command_for_func(): + called = False + def func(): + nonlocal called + called = True + + cmd = pkg.command_for_func(func) + cmd(Distribution()).run() + assert called + + +def test_install_npm(): + builder = pkg.install_npm() + assert issubclass(builder, pkg.BaseCommand) + + +def test__wrap_command(): + called = False + def func(self, cmd): + nonlocal called + called = True + + class TestCommand(pkg.BaseCommand): + def run(self): + pass + + cmd = pkg._wrap_command(['js'], TestCommand) + cmd.run_command = func + dist = Distribution() + cmd(dist).run() + assert called == True + diff --git a/tests/test_ensure_targets.py b/tests/test_ensure_targets.py deleted file mode 100644 index d88eafc..0000000 --- a/tests/test_ensure_targets.py +++ /dev/null @@ -1,20 +0,0 @@ - -import pytest - -from jupyter_packaging.setupbase import ensure_targets -from utils import run_command - - -def test_ensure_existing_targets(destination_dir): - local_targets = ['file1.rtf', 'sub/subfile1.rtf'] - targets = [str(destination_dir.join(t)) for t in local_targets] - cmd = ensure_targets(targets) - run_command(cmd) - - -def test_ensure_missing_targets(source_dir): - local_targets = ['file1.rtf', 'sub/subfile1.rtf'] - targets = [str(source_dir.join(t)) for t in local_targets] - cmd = ensure_targets(targets) - with pytest.raises(ValueError): - run_command(cmd) diff --git a/tests/test_find_packages.py b/tests/test_find_packages.py deleted file mode 100644 index c8f8ad9..0000000 --- a/tests/test_find_packages.py +++ /dev/null @@ -1,47 +0,0 @@ - -import os - -import pytest - -from jupyter_packaging.setupbase import find_packages - - -here = os.path.dirname(__file__) -root = os.path.join(here, os.pardir) - - -def test_finds_itself(): - with pytest.warns(DeprecationWarning): - assert ['jupyter_packaging'] == find_packages(root) - - -def test_finds_subpackages(tmpdir): - a = tmpdir.mkdir('packageA') - sub_a1 = a.mkdir('sub1') - sub_a2 = a.mkdir('sub2') - b = tmpdir.mkdir('packageB') - sub_b1 = b.mkdir('sub1') - sub_b2 = b.mkdir('sub2') - for d in (a, sub_a1, sub_a2, b, sub_b1, sub_b2): - d.join('__init__.py').write('') - # using sets ensure order won't matter - expected = set([ - 'packageA', 'packageA.sub1', 'packageA.sub2', - 'packageB', 'packageB.sub1', 'packageB.sub2' - ]) - with pytest.warns(DeprecationWarning): - found = set(find_packages(str(tmpdir))) - assert expected == found - - -def test_finds_only_direct_subpackages(tmpdir): - a = tmpdir.mkdir('packageA') - sub_a1 = a.mkdir('sub1') - sub_a2 = a.mkdir('sub2') - # No __init__.py in packageA: - for d in (sub_a1, sub_a2): - d.join('__init__.py').write('') - - expected = [] - with pytest.warns(DeprecationWarning): - assert expected == find_packages(str(tmpdir)) diff --git a/tests/test_skip_if_exists.py b/tests/test_skip_if_exists.py deleted file mode 100644 index 467cb26..0000000 --- a/tests/test_skip_if_exists.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest - -from jupyter_packaging.setupbase import skip_if_exists, BaseCommand -from utils import run_command - - -class TestCommand(BaseCommand): - def run(self): - raise RuntimeError() - - -def test_skip_existing(destination_dir): - local_targets = ['file1.rtf', 'sub/subfile1.rtf'] - targets = [str(destination_dir.join(t)) for t in local_targets] - cmd = skip_if_exists(targets, TestCommand) - run_command(cmd) - - -def test_no_skip_missing(source_dir): - local_targets = ['file1.rtf', 'sub/subfile1.rtf'] - targets = [str(source_dir.join(t)) for t in local_targets] - cmd = skip_if_exists(targets, TestCommand) - with pytest.raises(RuntimeError): - run_command(cmd) diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py new file mode 100644 index 0000000..334c085 --- /dev/null +++ b/tests/test_utility_functions.py @@ -0,0 +1,80 @@ + +from unittest.mock import patch +import pytest + +from setuptools.dist import Distribution +from jupyter_packaging.setupbase import __file__ as path +import jupyter_packaging.setupbase as pkg + +from utils import run_command + +def test_get_version(): + version = pkg.get_version(path) + assert version == pkg.__version__ + + +def test_combine_commands(): + class MockCommand(pkg.BaseCommand): + called = 0 + def run(self): + MockCommand.called += 1 + + combined_klass = pkg.combine_commands(MockCommand, MockCommand) + combined = combined_klass(Distribution()) + combined.initialize_options() + combined.finalize_options() + combined.run() + assert MockCommand.called == 2 + + +def test_run(): + assert pkg.run('python --version') == 0 + + with pytest.raises(ValueError): + pkg.run('foobarbaz') + + +def test_ensure_existing_targets(destination_dir): + local_targets = ['file1.rtf', 'sub/subfile1.rtf'] + targets = [str(destination_dir.join(t)) for t in local_targets] + cmd = pkg.ensure_targets(targets) + run_command(cmd) + + +def test_ensure_missing_targets(source_dir): + local_targets = ['file1.rtf', 'sub/subfile1.rtf'] + targets = [str(source_dir.join(t)) for t in local_targets] + cmd = pkg.ensure_targets(targets) + with pytest.raises(ValueError): + run_command(cmd) + + +def test_ensure_with_skip_npm(source_dir, mocker): + mocker.patch('jupyter_packaging.setupbase.skip_npm', True) + local_targets = ['file1.rtf', 'sub/subfile1.rtf'] + targets = [str(source_dir.join(t)) for t in local_targets] + cmd = pkg.ensure_targets(targets) + run_command(cmd) + + +class TestCommand(pkg.BaseCommand): + def run(self): + raise RuntimeError() + +# Prevent pytest from trying to collect TestCommand as a test: +TestCommand.__test__ = False + + +def test_skip_existing(destination_dir): + local_targets = ['file1.rtf', 'sub/subfile1.rtf'] + targets = [str(destination_dir.join(t)) for t in local_targets] + cmd = pkg.skip_if_exists(targets, TestCommand) + run_command(cmd) + + +def test_no_skip_missing(source_dir): + local_targets = ['file1.rtf', 'sub/subfile1.rtf'] + targets = [str(source_dir.join(t)) for t in local_targets] + cmd = pkg.skip_if_exists(targets, TestCommand) + with pytest.raises(RuntimeError): + run_command(cmd) diff --git a/tests/test_which.py b/tests/test_which.py deleted file mode 100644 index bb1d58e..0000000 --- a/tests/test_which.py +++ /dev/null @@ -1,8 +0,0 @@ - -import os -import sys -from jupyter_packaging.setupbase import which - - -def test_which_finds_python_executable(): - assert which(os.path.basename(sys.executable))