diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 989f74c..45d84ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,32 +15,37 @@ jobs: - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - - name: Set up Git + - name: Set up Git and Pip run: | git config --global user.email "you@example.com" git config --global user.name "Your Name" + python -m pip install --upgrade pip + python -m pip install wheel + git --version python --version - - name: Set up dummy Python project with miniver + - name: Install miniver run: | - cd .. - git init my_package - cd my_package - python ../miniver/ci/create_package.py - git add . - git commit -m "Initialization of package" - git tag -a 0.0.0 -m "First version for miniver" - - name: Install dummy Python project with miniver - run: | - cd ../my_package - pip install -e . - - name: Test versioning of dummy project + pip install . + - name: Set up minimal python packages run: | - python -c "import my_package; assert my_package.__version__ == '0.0.0'" - cd ../my_package - echo "# Extra comment" >> setup.py - python -c "import my_package; assert my_package.__version__.endswith('dirty')" - python -c "import my_package; assert my_package.__version__.startswith('0.0.0')" - git commit -a -m "new comment" - python -c "import my_package; assert my_package.__version__.startswith('0.0.0.dev1')" - git tag -a 0.0.1 -m "0.0.1" - python -c "import my_package; assert my_package.__version__ == '0.0.1'" + cd .. + # simple package + python miniver/ci/create_package.py simple-distr simple_pkg + # simple package in 'src' layout + python miniver/ci/create_package.py simple-src-distr simple_src_pkg --src-layout + # namespace package + python miniver/ci/create_package.py ns-distr nspkg.simple_pkg + # namespace package in 'src' layout + python miniver/ci/create_package.py ns-src-distr nspkg.simple_src_pkg --src-layout + - name: Test versioning of simple package + shell: bash + run: cd .. && miniver/ci/test_package.sh simple-distr simple_pkg + - name: Test versioning of simple src-layout package + shell: bash + run: cd .. && miniver/ci/test_package.sh simple-src-distr simple_src_pkg + - name: Test versioning of namespace package + shell: bash + run: cd .. && miniver/ci/test_package.sh ns-distr nspkg.simple_pkg + - name: Test versioning of namespace src-layout package + shell: bash + run: cd .. && miniver/ci/test_package.sh ns-src-distr nspkg.simple_src_pkg diff --git a/CHANGELOG.md b/CHANGELOG.md index b7002a0..09bdd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a 'ver' command that prints the detected version - Running 'miniver' without any arguments invokes the 'ver' command +- Miniver now works with namespace packages ## [0.7.0] - 2020-08-15 ### Added diff --git a/README.md b/README.md index 73f7247..399afba 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ works with Python 2](https://github.com/cmarquardt/miniver2) ## Usage The simplest way to use Miniver is to run the following in your project root: ``` -curl https://raw.githubusercontent.com/jbweston/miniver/master/bin/miniver | python - install +curl https://raw.githubusercontent.com/jbweston/miniver/master/miniver/app.py | python - install ``` This will grab the latest files from GitHub and set up Miniver for your project. @@ -69,15 +69,18 @@ del _version ```python # Your project's setup.py +from setuptools import setup + # Loads _version.py module without importing the whole package. -def get_version_and_cmdclass(package_name): +def get_version_and_cmdclass(pkg_path): import os from importlib.util import module_from_spec, spec_from_file_location - spec = spec_from_file_location('version', - os.path.join(package_name, '_version.py')) + spec = spec_from_file_location( + 'version', os.path.join(pkg_path, '_version.py'), + ) module = module_from_spec(spec) spec.loader.exec_module(module) - return module.__version__, module.get_cmdclass() + return module.__version__, module.get_cmdclass(pkg_path) version, cmdclass = get_version_and_cmdclass('my_package') diff --git a/ci/create_package.py b/ci/create_package.py index 58b75d0..f6707a3 100644 --- a/ci/create_package.py +++ b/ci/create_package.py @@ -1,35 +1,95 @@ -from pathlib import Path -from shutil import copyfile - -Path("my_package").mkdir(exist_ok=True) -copyfile("../miniver/miniver/_static_version.py", "my_package/_static_version.py") -copyfile("../miniver/miniver/_version.py", "my_package/_version.py") - -README_filename = "../miniver/README.md" - - -def write_snippet_from_readme(outfile, start_marker, file_header=None): - # Create the setup file - with open(README_filename) as f: - for line in f: - if line.startswith(start_marker): - break - else: - raise RuntimeError( - "Could not find start_marker: {}" "".format(start_marker) - ) - with open(outfile, "w") as out: - out.write(line) - if file_header is not None: - out.write(file_header) - for line in f: - if line.startswith("```"): - break - out.write(line) - - -write_snippet_from_readme( - "setup.py", "# Your project's setup.py", "from setuptools import setup\n" -) -write_snippet_from_readme(".gitattributes", "# Your project's .gitattributes") -write_snippet_from_readme("my_package/__init__.py", "# Your package's __init__.py") +import argparse +from contextlib import contextmanager +from functools import partial +import os.path +from os import chdir, makedirs +from shutil import rmtree +from subprocess import run, PIPE, CalledProcessError +from sys import exit, stderr +from textwrap import indent + + +@contextmanager +def log(msg, where=stderr): + pp = partial(print, file=where) + pp("{}...".format(msg), end="") + try: + yield + except KeyboardInterrupt: + pp("INTERRUPTED") + exit(2) + except CalledProcessError as e: + pp("FAILED") + pp("Subprocess '{}' failed with exit code {}".format(e.cmd, e.returncode)) + if e.stdout: + pp("---- stdout ----") + pp(e.stdout.decode()) + pp("----------------") + if e.stderr: + pp("---- stderr ----") + pp(e.stderr.decode()) + pp("----------------") + exit(e.returncode) + except Exception as e: + print("FAILED", file=where) + print(str(e), file=where) + exit(1) + else: + print("OK", file=where) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("distribution", help="Distribution package name") + parser.add_argument("package", help="Dotted package name") + parser.add_argument("--src-layout", action="store_true") + args = parser.parse_args() + + distr, pkg, src_pkg = (getattr(args, x) for x in ("distribution", "package", "src_layout")) + + path = os.path.join("src" if src_pkg else "", pkg.replace(".", os.path.sep)) + + with log("Ensuring '{}' is removed".format(distr)): + rmtree(args.distribution, ignore_errors=True) + + with log("Initializing git repository in '{}'".format(distr)): + run("git init {}".format(distr), shell=True, check=True, stdout=PIPE, stderr=PIPE) + chdir(distr) + makedirs(path) + + with log("Installing miniver in '{}'".format(os.path.join(distr, path))): + r = run( + "miniver install {}".format(path), + shell=True, + check=True, + stdout=PIPE, + stderr=PIPE, + ) + setup_template = r.stdout.decode("utf8") + + with log("Writing gitignore"): + with open(".gitignore", "w") as f: + f.write("\n".join([ + "dist", + "build", + "__pycache__", + "*.egg-info", + ])) + + with log("Writing setup.py"): + lines = [ + "name='{}',".format(distr), + "packages=['{}'],".format(pkg), + ] + if src_pkg: + lines.append("package_dir={'': 'src'},") + replacement = indent("\n".join(lines), " ") + + with open("setup.py", "w") as f: + # This is tightly coupled to the setup.py template: there is a + # call to 'setup()' with an ellipsis on a single line. + f.write(setup_template.replace(" ...,", replacement)) + + +if __name__ == "__main__": + main() diff --git a/ci/test_package.sh b/ci/test_package.sh new file mode 100755 index 0000000..c08bf4d --- /dev/null +++ b/ci/test_package.sh @@ -0,0 +1,82 @@ +set -e + +distr=$1 +pkg=$2 + +function test_version() { + echo "Testing: $pkg.__version__$1" + python -c "import $pkg; assert $pkg.__version__$1" +} + +# "./" to ensure we don't pull from PyPI +pip install -e ./$distr + +test_version "== 'unknown'" + +pushd $distr +git add . +git commit -m "First commit" +git tag -a 0.0.0 -m "0.0.0" +echo "Tagged 0.0.0" +popd + +test_version "== '0.0.0'" + +pushd $distr +echo "# Extra comment" >> setup.py +echo "Modified working directory" +popd + +test_version ".startswith('0.0.0')" +test_version ".endswith('dirty')" + +pushd $distr +git commit -a -m "new comment" +echo "Committed changes" +popd + +test_version ".startswith('0.0.0.dev1')" + +pushd $distr +git tag -a 0.0.1 -m "0.0.1" +echo "Tagged 0.0.1" +popd + +test_version "== '0.0.1'" + +# Now test against "real" (non-editable) installations +pip uninstall -y $distr + +pushd $distr +git commit --allow-empty -m 'next commit' +git tag -a 0.0.2 -m "0.0.2" +echo "Tagged 0.0.2" +popd + +# First a source distribution + +echo "Testing setup.py sdist" +pushd $distr +python setup.py sdist +pip install dist/*.tar.gz +popd + +test_version "== '0.0.2'" + +pip uninstall -y $distr + +pushd $distr +git commit --allow-empty -m 'final commit' +git tag -a 0.0.3 -m "0.0.3" +echo "Tagged 0.0.3" +popd + +# Then a wheel distribution + +echo "Testing setup.py bdist_wheel" +pushd $distr +python setup.py bdist_wheel +pip install dist/*.whl +popd + +test_version "== '0.0.3'" diff --git a/miniver/_version.py b/miniver/_version.py index d5d7368..21abb63 100644 --- a/miniver/_version.py +++ b/miniver/_version.py @@ -15,14 +15,6 @@ package_root = os.path.dirname(os.path.realpath(__file__)) package_name = os.path.basename(package_root) -distr_root = os.path.dirname(package_root) -# If the package is inside a "src" directory the -# distribution root is 1 level up. -if os.path.split(distr_root)[1] == "src": - _package_root_inside_src = True - distr_root = os.path.dirname(distr_root) -else: - _package_root_inside_src = False STATIC_VERSION_FILE = "_static_version.py" @@ -70,23 +62,6 @@ def pep440_format(version_info): def get_version_from_git(): - try: - p = subprocess.Popen( - ["git", "rev-parse", "--show-toplevel"], - cwd=distr_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except OSError: - return - if p.wait() != 0: - return - if not os.path.samefile(p.communicate()[0].decode().rstrip("\n"), distr_root): - # The top-level directory of the current Git repository is not the same - # as the root directory of the distribution: do not extract the - # version from Git. - return - # git describe --first-parent does not take into account tags from branches # that were merged-in. The '--long' flag gets us the 'dev' version and # git hash, '--always' returns the git hash even if there are no tags. @@ -94,7 +69,7 @@ def get_version_from_git(): try: p = subprocess.Popen( ["git", "describe", "--long", "--always"] + opts, - cwd=distr_root, + cwd=package_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -128,7 +103,7 @@ def get_version_from_git(): labels.append(git) try: - p = subprocess.Popen(["git", "diff", "--quiet"], cwd=distr_root) + p = subprocess.Popen(["git", "diff", "--quiet"], cwd=package_root) except OSError: labels.append("confused") # This should never happen. else: @@ -169,9 +144,9 @@ def get_version_from_git_archive(version_info): __version__ = get_version() -# The following section defines a module global 'cmdclass', -# which can be used from setup.py. The 'package_name' and -# '__version__' module globals are used (but not modified). +# The following section defines a 'get_cmdclass' function +# that can be used from setup.py. The '__version__' module +# global is used (but not modified). def _write_version(fname): @@ -188,13 +163,20 @@ def _write_version(fname): ) -def get_cmdclass(prefix_dir=""): +def get_cmdclass(pkg_source_path): class _build_py(build_py_orig): def run(self): super().run() + + src_marker = "".join(["src", os.path.sep]) + + if pkg_source_path.startswith(src_marker): + path = pkg_source_path[len(src_marker):] + else: + path = pkg_source_path _write_version( os.path.join( - self.build_lib, prefix_dir, package_name, STATIC_VERSION_FILE + self.build_lib, path, STATIC_VERSION_FILE ) ) @@ -202,7 +184,7 @@ class _sdist(sdist_orig): def make_release_tree(self, base_dir, files): super().make_release_tree(base_dir, files) _write_version( - os.path.join(base_dir, prefix_dir, package_name, STATIC_VERSION_FILE) + os.path.join(base_dir, pkg_source_path, STATIC_VERSION_FILE) ) return dict(sdist=_sdist, build_py=_build_py) diff --git a/bin/miniver b/miniver/app.py similarity index 91% rename from bin/miniver rename to miniver/app.py index b8ce87b..e422013 100755 --- a/bin/miniver +++ b/miniver/app.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # This file is part of 'miniver': https://github.com/jbweston/miniver import sys @@ -38,7 +37,9 @@ # File templates _setup_template = textwrap.dedent( ''' - def get_version_and_cmdclass(package_path): + from setuptools import setup + + def get_version_and_cmdclass(pkg_path): """Load version.py module without importing the whole package. Template code from miniver @@ -46,13 +47,13 @@ def get_version_and_cmdclass(package_path): import os from importlib.util import module_from_spec, spec_from_file_location - spec = spec_from_file_location("version", os.path.join(package_path, "_version.py")) + spec = spec_from_file_location("version", os.path.join(pkg_path, "_version.py")) module = module_from_spec(spec) spec.loader.exec_module(module) - return module.__version__, module.cmdclass + return module.__version__, module.get_cmdclass(pkg_path) - version, cmdclass = get_version_and_cmdclass("{package_dir}") + version, cmdclass = get_version_and_cmdclass(r"{package_dir}") setup( @@ -201,11 +202,13 @@ def install(args): "You still have to copy the following snippet into your 'setup.py':" ) ) - print("\n".join((msg, _setup_template)).format(package_dir=package_dir)) + # Use stdout for setup template only, so it can be redirected to 'setup.py' + print(msg.format(package_dir=package_dir), file=sys.stderr) + print(_setup_template.format(package_dir=package_dir), file=sys.stdout) def ver(args): - search_path = args.search_path + search_path = os.path.realpath(args.search_path) try: version_location, = glob.glob( os.path.join(search_path, "**", "_version.py"), @@ -244,5 +247,7 @@ def main(): args.dispatch(args) +# This is needed when using the script directly from GitHub, but not if +# miniver is installed. if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index a39d837..4f6eacc 100644 --- a/setup.py +++ b/setup.py @@ -9,14 +9,14 @@ # Loads version.py module without importing the whole package. -def get_version_and_cmdclass(package_name): +def get_version_and_cmdclass(pkg_path): import os from importlib.util import module_from_spec, spec_from_file_location - spec = spec_from_file_location("version", os.path.join(package_name, "_version.py")) + spec = spec_from_file_location("version", os.path.join(pkg_path, "_version.py")) module = module_from_spec(spec) spec.loader.exec_module(module) - return module.__version__, module.get_cmdclass() + return module.__version__, module.get_cmdclass(pkg_path) version, cmdclass = get_version_and_cmdclass("miniver") @@ -50,5 +50,9 @@ def get_version_and_cmdclass(package_name): ], packages=find_packages("."), cmdclass=cmdclass, - scripts=["bin/miniver"], + entry_points={ + "console_scripts": [ + "miniver=miniver.app:main", + ] + }, )