From: TizenOpenSource Date: Tue, 6 Feb 2024 05:21:33 +0000 (+0900) Subject: Imported Upstream version 8.0.4 X-Git-Tag: upstream/8.0.4^0 X-Git-Url: http://review.tizen.org/git/?a=commitdiff_plain;h=d3b4be35f96fe4a0a28964fafadb69aaed978e34;p=platform%2Fupstream%2Fpython3-setuptools_scm.git Imported Upstream version 8.0.4 --- diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00a7b00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.git_archival.txt export-subst diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cbd920f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index c154a12..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - branches: [master] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v1 - - name: set PY - run: echo "::set-env name=PY::$(python --version --version | sha256sum | cut -d' ' -f1)" - - uses: actions/cache@v1 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - - uses: pre-commit/action@v1.0.0 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index ca8df3f..d0b92c0 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -4,152 +4,123 @@ on: pull_request: push: branches: - - master + - "*" tags: - "v*" - release: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 jobs: + + package: + name: Build & inspect our package. + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: hynek/build-and-inspect-python-package@v1 + test: + needs: [package] runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python_version: [ '2.7', '3.5', '3.6', '3.7', '3.8', 'pypy2', 'pypy3' ] + python_version: [ '3.8', '3.9', '3.10', '3.11', '3.12.0-beta - 3.12', 'pypy-3.8' ] os: [windows-latest, ubuntu-latest] #, macos-latest] - exclude: - - os: windows-latest - python_version: "pypy2" include: - - os: ubuntu-latest - python_version: '3.9-dev' + - os: windows-latest + python_version: 'msys2' name: ${{ matrix.os }} - Python ${{ matrix.python_version }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup python - uses: actions/setup-python@v2 - if: matrix.python_version != '3.9-dev' + uses: actions/setup-python@v4 + if: matrix.python_version != 'msys2' with: python-version: ${{ matrix.python_version }} architecture: x64 - - name: Set up Python ${{ matrix.python_version }} (deadsnakes) - uses: deadsnakes/action@v1.0.0 - if: matrix.python_version == '3.9-dev' + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + if: matrix.python_version == 'msys2' with: - python-version: ${{ matrix.python_version }} - architecture: x64 - - run: pip install -U setuptools - - run: pip install -e .[toml] pytest - - run: pytest - - check_selfinstall: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python_version: [ '2.7', '3.5', '3.6', '3.7', '3.8', 'pypy2', 'pypy3' ] - name: check self install - Python ${{ matrix.python_version }} - steps: - - uses: actions/checkout@v1 - - name: Setup python - uses: actions/setup-python@v2 + msystem: MINGW64 + install: git mingw-w64-x86_64-python mingw-w64-x86_64-python-setuptools + update: true + - name: Setup GnuPG + # At present, the Windows VMs only come with the copy of GnuPG that's bundled + # with Git for Windows. If we want to use this version _and_ be able to set + # arbitrary GnuPG home directories, then the test would need to figure out when + # to convert Windows-style paths into Unix-style paths with cygpath, which is + # unreasonable. + # + # Instead, we'll install a version of GnuPG that can handle Windows-style paths. + # However, due to , installation fails if the PATH + # environment variable is too long. Consequently, we need to shorten PATH to + # something minimal before we can install GnuPG. For further details, see + # . + # + # Additionally, we'll explicitly set `gpg.program` to ensure Git for Windows + # doesn't invoke the bundled GnuPG, otherwise we'll run into + # . See also: . + run: | + $env:PATH = "C:\Program Files\Git\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\ProgramData\Chocolatey\bin" + [Environment]::SetEnvironmentVariable("Path", $env:PATH, "Machine") + choco install gnupg -y --no-progress + echo "C:\Program Files (x86)\gnupg\bin" >> $env:GITHUB_PATH + git config --system gpg.program "C:\Program Files (x86)\gnupg\bin\gpg.exe" + if: runner.os == 'Windows' + - run: pip install -U 'setuptools>=61' + - uses: actions/download-artifact@v3 with: - python-version: ${{ matrix.python_version }} - architecture: x64 - # self install testing needs some clarity - # so its being executed without any other tools running - - run: pip install -U setuptools - - run: python setup.py egg_info - - run: python setup.py sdist - - run: easy_install dist/* - - run: python testing/check_self_install.py + name: Packages + path: dist + - shell: bash + run: pip install "$(echo -n dist/*whl)[toml,test]" + - run: | + $(hg debuginstall --template "{pythonexe}") -m pip install hg-git --user + if: matrix.os == 'ubuntu-latest' + # this hopefull helps with os caches, hg init sometimes gets 20s timeouts + - run: hg version + - run: pytest + timeout-minutes: 15 + dist_upload: - eggs: runs-on: ubuntu-latest - + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') + permissions: + id-token: write needs: [test] - name: Python ${{ matrix.python_version }} eggs - strategy: - matrix: - python_version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9-dev'] steps: - - uses: actions/checkout@v1 - - name: Setup python - uses: actions/setup-python@v2 - if: matrix.python_version != '3.9-dev' - with: - python-version: ${{ matrix.python_version }} - architecture: x64 - - name: Set up Python ${{ matrix.python_version }} (deadsnakes) - uses: deadsnakes/action@v1.0.0 - if: matrix.python_version == '3.9-dev' - with: - python-version: ${{ matrix.python_version }} - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install --upgrade wheel setuptools - - run: python setup.py egg_info - - name: Build package - run: python setup.py bdist_egg - - uses: actions/upload-artifact@v2 + - uses: actions/download-artifact@v3 with: - name: dist + name: Packages path: dist + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 - dist: + test-pypi-upload: runs-on: ubuntu-latest - needs: [test] - name: Python bdist/wheel - steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v1 - with: - python-version: "3.8" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install --upgrade wheel setuptools - - run: python setup.py egg_info - - name: Build package - run: python setup.py bdist_wheel sdist - - uses: actions/upload-artifact@v2 - with: - name: dist - path: dist - - - dist_check: - runs-on: ubuntu-latest - needs: [eggs, dist] - steps: - - uses: actions/setup-python@v2 - with: - python-version: "3.8" - - name: Install dependencies - run: pip install twine - - uses: actions/download-artifact@v2 - with: - name: dist - path: dist - - run: twine check dist/* - - dist_upload: - - runs-on: ubuntu-latest - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') - needs: [dist_check] + permissions: + id-token: write steps: - - uses: actions/download-artifact@v2 + - uses: actions/download-artifact@v3 with: - name: dist + name: Packages path: dist - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@master + continue-on-error: true + uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ - password: ${{ secrets.pypi_token }} + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c51b132..0000000 --- a/.gitignore +++ /dev/null @@ -1,48 +0,0 @@ -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion - -*.iml - -## Directory-based project format: -.idea/ - -### Other editors -.*.swp - - -### Python template -# Byte-compiled / optimized -__pycache__/ -*.py[cod] -*$py.class - - -# Distribution / packaging -.env/ -env/ -.venv/ -venv/ -build/ -dist/ -.eggs/ -lib/ -lib64/ -*.egg-info/ - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -.pytest_cache -nosetests.xml -coverage.xml -*,cover - -# Sphinx documentation -docs/_build/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db8efbe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,714 @@ + +# v8.0.4 + +## Changed + +- introduce scriv for changelog management +- reconfigure local build backend to use an attribute instead of star imports from setuptools +- introduce ruff as a linter +- ensure the setuptools version keyword correctly load pyproject.toml configuration +- add build and wheel to the test requirements for regression testing +- move internal toml handling to own module + +## Fixed + +- fix #925: allow `write_to` to be an absolute path when it's a subdirectory of the root +- fix #932: ensure type annotations in version file don't cause linter issues +- fix #930: temporary restore `DEFAULT_VERSION_SCHEME` and `DEFAULT_LOCAL_SCHEME` on the `setuptools_scm` package + + + +# v8.0.3 + +## bugfix + +- fix #918 for good - remove external importlib-metadata to avoid source only loop +- fix #926: ensure mypy on python3.8 works with the version file + +# v8.0.2 + +## bugfix + +- fix #919: restore legacy version-file behaviour for external callers + add Deprecation warning +- fix #918: use packaging from setuptools for self-build +- fix #914: ignore the deprecated git archival plugin as its integrated now +- fix #912: ensure mypy safety of the version template + regression test +- fix #913: use 240s timeout instead of 20 for `git unshallow` + to account for large repos or slow connections + + +# v8.0.1 + +## bugfix + +- update version file template to work on older python versions by using type comments +- ensure tag regex from setup.py is parsed into regex + +# v8.0.0 + +## breaking + +- remove legacy version parser api - config arg always required +- turn Configuration into a dataclass +- require configuration to always pass into helpers +- hide file-finders implementation in private module +- renamed setuptools_scm.hacks to setuptools_scm.fallbacks and drop support for pip-egg-info +- remove trace function and use logging instead +- unify `distance=None` and `distance=0` they should mean the same andwhere hiding dirty states that are now explicitly dirty +- depend on later importlib for the full selectable api +- move setuptools integration code to private sub-package +- use normalized dist names for the `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${DIST_NAME}` env var +- drop support for python 3.7 +- introduce `version_file` as replacement for `write_to` + +## features + +- created a directory for the vcs-versioning package and added it to pypi +- git: expect main as possible default branch +- drop version_from_scm helper +- trim down exposed public api +- no longer self-call twice in setuptools +- add support for version schemes by import +- chores + + - migrate own metadata to pyproject.toml + - consolidate version schemes + - stricter tag typing + - pre-compiled regex + - move helpers to private modules + +- support passing log levels to SETUPTOOLS_SCM_DEBUG +- support using rich.logging as console log handler if installed +- fix #527: type annotation in default version template +- fix #549: use fallbacks when scm search raises CommandNotFoundError + +## bugfixes + +- fix #883: use HeadersParser to ensure only mime metadata in headers is used +- fix #884: parse calver dates from versions with the v prefix +- don't use a C locale without UTF-8 support, when running commands. + +# v7.1.0 + +- #748: use `tomllib` from stdlib +- fix #762: handle non-ascii in setup.cfg +- #752: implement fallback file finders for archives +- #765: removed coding header in python template +- declared Python 3.11 support +- fix #759: update .git_archival.txt templates match git-describe invocation +- fix #772: fix handling of .git-archival.txt from tagged commit + +# v7.0.5 + +- fixes #742, #745: correctly hande accidentally released archival files + +# v7.0.4 + +- fix #727: correctly handle incomplete archival from setuptools_scm_git_archival +- fix #691: correctly handle specifying root in pyproject.toml +- correct root override check condition (to ensure absolute path matching) +- allow root by the cli to be considered relative to the cli (using abspath) + +# v7.0.3 + +- fix mercurial usage when pip primes a isolated environment +- fix regression for branch names on git + add a test + +# v7.0.2 + +- fix #723 and #722: remove bootstrap dependencies +- ensure we read the distribution name from `setup.cfg` if needed even for `pyproject.toml` + +# v7.0.1 + +- fix #718: Avoid `ModuleNotFoundError` by requiring `importlib_metadata` in `python<3.8` + +# v7.0.0 + +- drop python 3.6 support +- include git archival support +- fix #707: support git version detection even when git protects against mismatched owners + (common with misconfigured containers, thanks @chrisburr ) +- fix #548: correctly handle parsing the commit timestamp of HEAD when `log.showSignature` is set + +# v6.4.2 + +- fix #671: `NoReturn` is not available in painfully dead python 3.6 + +# v6.4.1 + +- fix regression #669: restore get_version signature +- fix #668: harden the self-test for distribution extras + +# 6.4.0 + +- compatibility adjustments for setuptools \>58 +- only put minimal setuptools version into toml extra to warn people with old strict pins +- correctly handle hg-git self-use +- better mercurial detection +- modernize packaging setup +- python 3.10 support +- better handling of setuptools install command deprecation +- consider `pyproject.tomls` when running as command +- use list in git describe command to avoid shell expansions while supporting both windows and posix +- add `--strip-dev` flag to `python -m setuptools_scm` to print the next guessed version cleanly +- ensure no-guess-dev will fail on bad tags instead of generating invalid versions +- ensure we use utc everywhere to avoid confusion + +# 6.3.2 + +- fix #629: correctly convert Version data in tags_to_version parser to avoid errors + +# 6.3.1 + +- fix #625: restore tomli in install_requires after the regression changes in took it out + and some users never added it even tho they have pyproject.toml files + +# 6.3.0 + +## warning + +This release explicitly warns on unsupported setuptools. This +unfortunately has to happen as the legacy `setup_requires` mechanism +incorrectly configures the setuptools working-set when a more recent +setuptools version than available is required. + +As all releases of setuptools are affected as the historic mechanism for +ensuring a working setuptools setup was shipping a `ez_setup` file next +to `setup.py`, which would install the required version of setuptools. + +This mechanism has long since been deprecated and removed as most people +haven\'t been using it + +## bugfixes + +- fix #612: depend on packaging to ensure version parsing parts +- fix #611: correct the typo that hid away the toml extra and add it in `setup.py` as well +- fix #615: restore support for the git_archive plugin which doesn't pass over the config +- restore the ability to run on old setuptools while to avoid breaking pipelines + +# v6.2.0 + +- fix #608: resolve tomli dependency issue by making it a hard + dependency as all intended/supported install options use pip/wheel + this is only a feature release +- ensure python 3.10 works + +# v6.1.1 + +- fix #605: completely disallow bdist_egg - modern enough + setuptools\>=45 uses pip +- fix #606: re-integrate and harden toml parsing +- fix #597: harden and expand support for figuring the current + distribution name from [pyproject.toml]{.title-ref} + ([project.name]{.title-ref} or + [tool.setuptools_scm.dist_name]{.title-ref}) section or + [setup.cfg]{.title-ref} ([metadata.name]{.title-ref}) + +# v6.1.0 + +- fix #587: don\'t fail file finders when distribution is not given +- fix #524: new parameters `normalize` and `version_cls` to customize + the version normalization class. +- fix #585: switch from toml to tomli for toml 1.0 support +- fix #591: allow to opt in for searching parent directories in the + api +- fix #589: handle yaml encoding using the expected defaults +- fix #575: recommend storing the version_module inside of + `mypkg/_version.py` +- fix #571: accept branches starting with `v` as release branches +- fix #557: Use `packaging.version` for `version_tuple` +- fix #544: enhance errors on unsupported python/setuptools versions + +# v6.0.1 + +- fix #537: drop node_date on old git to avoid errors on missing %cI + +# v6.0.0 + +- fix #517: drop dead python support \>3.6 required +- drop dead setuptools support \> 45 required (can install wheels) +- drop egg building (use wheels) +- add git node_date metadata to get the commit time-stamp of HEAD +- allow version schemes to be priority ordered lists of version + schemes +- support for calendar versioning (calver) by date + +# v5.0.2 + +- fix #415: use git for matching prefixes to support the windows + situation + +# v5.0.1 + +- fix #509: support `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${DISTRIBUTION_NAME}` for `pyproject.toml` + +# v5.0.0 + +Breaking changes: + +- fix #339: strict errors on missing scm when parsing a scm dir to + avoid false version lookups +- fix #337: if relative_to is a directory instead of a file, consider + it as direct target instead of the containing folder and print a + warning + +Bugfixes: + +- fix #352: add support for generally ignoring specific vcs roots +- fix #471: better error for version bump failing on complex but + accepted tag +- fix #479: raise indicative error when tags carry non-parsable + information +- Add `no-guess-dev` which does no next version guessing, + just adds `.post1.devN` in case there are new commits after the tag +- add python3.9 +- enhance documentation +- consider SOURCE_DATE_EPOCH for versioning +- add a version_tuple to write_to templates +- fix #321: add support for the + `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${DISTRIBUTION_NAME}` env var to + target the pretend key +- fix #142: clearly list supported scm +- fix #213: better error message for non-zero dev numbers in tags +- fix #356: add git branch to version on describe failure + +# v4.1.2 + +- disallow git tags without dots by default again - #449 + +# v4.1.1 + +- drop jaraco.windows from pyproject.toml, allows for wheel builds on + python2 + +# v4.1.0 + +- include python 3.9 via the deadsnakes action +- return release_branch_semver scheme (it got dropped in a bad rebase) +- undo the devendoring of the samefile backport for python2.7 on + windows +- re-enable the building of universal wheels +- fix handling of missing git/hg on python2.7 (python 3 exceptions + where used) +- correct the tox flake8 invocation +- trigger builds on tags again + +# v4.0.0 + +- Add `parentdir_prefix_version` to support installs from GitHub + release tarballs. +- use Coordinated Universal Time (UTC) +- switch to github actions for ci +- fix documentation for `tag_regex` and add support for single digit + versions +- document handling of enterprise distros with unsupported setuptools + versions #312 +- switch to declarative metadata +- drop the internal copy of samefile and use a dependency on + jaraco.windows on legacy systems +- select git tags based on the presence of numbers instead of dots +- enable getting a version form a parent folder prefix +- add release-branch-semver version scheme +- make global configuration available to version metadata +- drop official support for python 3.4 + +# v3.5.0 + +- add `no-local-version` local scheme and improve documentation for + schemes + +# v3.4.4 + +- fix #403: also sort out resource warnings when dealing with git file + finding + +# v3.4.3 + +- fix #399: ensure the git file finder terminates subprocess after + reading archive + +# v3.4.2 + +- fix #395: correctly transfer tag regex in the Configuration + constructor +- rollback \--first-parent for git describe as it turns out to be a + regression for some users + +# v3.4.1 + +- pull in #377 to fix #374: correctly set up the default version + scheme for pyproject usage. this bugfix got missed when rushing the + release. + +# v3.4.0 + +- fix #181 - add support for projects built under setuptools + declarative config by way of the + setuptools.finalize_distribution_options hook in Setuptools 42. +- fix #305 - ensure the git file finder closes file descriptors even + when errors happen +- fix #381 - clean out env vars from the git hook system to ensure + correct function from within +- modernize docs wrt importlib.metadata + +*edited* + +- use \--first-parent for git describe + +# v3.3.3 + +- add eggs for python3.7 and 3.8 to the deploy + +# v3.3.2 + +- fix #335 - fix python3.8 support and add builds for up to python3.8 + +# v3.3.1 + +- fix #333 (regression from #198) - use a specific fallback root when + calling fallbacks. Remove old hack that resets the root when + fallback entrypoints are present. + +# v3.3.0 + +- fix #198 by adding the `fallback_version` option, which sets the + version to be used when everything else fails. + +# v3.2.0 + +\* fix #303 and #283 by adding the option `git_describe_command` to +allow the user to control the way that [git describe]{.title-ref} is +called. + +# v3.1.0 + +- fix #297 - correct the invocation in version_from_scm and deprecate + it as its exposed by accident +- fix #298 - handle git file listing on empty repositories +- fix #268 - deprecate ScmVersion.extra + +# v3.0.6 + +- fix #295 - correctly handle self install from tarballs + +# v3.0.5 + +- fix #292 - match leading `V` character as well + + + +# v3.0.4 + +- re-release of 3.0.3 after fixing the release process + +v3.0.3 (pulled from pypi due to a packaging issue) ====== + +- fix #286 - duo an oversight a helper function was returning a + generator instead of a list + +# v3.0.2 + +- fix a regression from tag parsing - support for multi-dashed + prefixes - #284 + +# v3.0.1 + +- fix a regression in setuptools_scm.git.parse - reorder arguments so + the positional invocation from before works as expected #281 + +# v3.0.0 + +- introduce pre-commit and use black +- print the origin module to help testing +- switch to src layout (breaking change) +- no longer alias tag and parsed_version in order to support + understanding a version parse failure +- require parse results to be ScmVersion or None (breaking change) +- fix #266 by requiring the prefix word to be a word again (breaking + change as the bug allowed arbitrary prefixes while the original + feature only allowed words\") +- introduce an internal config object to allow the configuration for + tag parsing and prefixes (thanks to \@punkadiddle for introducing it + and passing it through) + +# v2.1.0 + +- enhance docs for sphinx usage +- add symlink support to file finder for git #247 (thanks Stéphane + Bidoul) +- enhance tests handling win32 (thanks Stéphane Bidoul) + +# v2.0.0 + +- fix #237 - correct imports in code examples +- improve mercurial commit detection (thanks Aaron) +- breaking change: remove support for setuptools before parsed + versions +- reintroduce manifest as the travis deploy can\'t use the file finder +- reconfigure flake8 for future compatibility with black +- introduce support for branch name in version metadata and support a + opt-in simplified semver version scheme + +# v1.17.0 + +- fix regression in git support - use a function to ensure it works in + egg installed mode + +- actually fail if file finding fails in order to see broken setups + instead of generating broken dists + + (thanks Mehdi ABAAKOUK for both) + +# v1.16.2 + +- fix regression in handling git export ignores (thanks Mehdi + ABAAKOUK) + +# v1.16.1 + +- fix regression in support for old setuptools versions (thanks Marco + Clemencic) + +# v1.16.0 + +- drop support for eol python versions +- #214 - fix misuse in surrogate-escape api +- add the node-and-timestamp local version scheme +- respect git export ignores +- avoid shlex.split on windows +- fix #218 - better handling of mercurial edge-cases with tag commits + being considered as the tagged commit +- fix #223 - remove the dependency on the internal `SetuptoolsVersion` + as it was removed after long-standing deprecation + +v1.15.7 ====== + +- Fix #174 with #207: Re-use samefile backport as developed in + jaraco.windows, and only use the backport where samefile is not + available. + +# v1.15.6 + +- fix #171 by unpinning the py version to allow a fixed one to get + installed + +# v1.15.5 + +- fix #167 by correctly respecting preformatted version metadata from + PKG-INFO/EGG-INFO + +# v1.15.4 + +- fix issue #164: iterate all found entry points to avoid errors when + pip remakes egg-info +- enhance self-use to enable pip install from github again + +# v1.15.3 + +- bring back correctly getting our version in the own sdist, finalizes + #114 +- fix issue #150: strip local components of tags + +# v1.15.2 + +- fix issue #128: return None when a scm specific parse fails in a + worktree to ease parse reuse + +# v1.15.1 + +- fix issue #126: the local part of any tags is discarded when + guessing new versions +- minor performance optimization by doing fewer git calls in the usual + cases + +# v1.15.0 + +- more sophisticated ignoring of mercurial tag commits when + considering distance in commits (thanks Petre Mierlutiu) +- fix issue #114: stop trying to be smart for the sdist and ensure it's + always correctly using itself +- update trove classifiers +- fix issue #84: document using the installed package metadata for + sphinx +- fix issue #81: fail more gracious when git/hg are missing +- address issue #93: provide an experimental api to customize + behaviour on shallow git repos a custom parse function may pick pre + parse actions to do when using git + +# v1.14.1 + +- fix #109: when detecting a dirty git workdir + + don't consider untracked file (this was a regression due to #86 in v1.13.1) +- consider the distance 0 when the git node is unknown (happens when you haven't committed anything) + +# v1.14.0 + +- publish bdist_egg for python 2.6, 2.7 and 3.3-3.5 +- fix issue #107 - dont use node if it is None + +# v1.13.1 + +- fix issue #86 - detect dirty git workdir without tags + +# v1.13.0 + +- fix regression caused by the fix of #101 + - assert types for version dumping + - strictly pass all versions through parsed version metadata + +# v1.12.0 + +- fix issue #97 - add support for mercurial plugins +- fix issue #101 - write version cache even for pretend version + (thanks anarcat for reporting and fixing) + +# v1.11.1 + +- fix issue #88 - better docs for sphinx usage (thanks Jason) +- fix issue #89 - use normpath to deal with windows (thanks Te-jé + Rodgers for reporting and fixing) + +# v1.11.0 + +- always run tag_to_version so in order to handle prefixes on old + setuptools (thanks to Brian May) +- drop support for python 3.2 +- extend the error message on missing scm metadata (thanks Markus + Unterwaditzer) +- fix bug when using callable version_scheme (thanks Esben Haabendal) + +# v1.10.1 + +- fix issue #73 - in hg pre commit merge, consider parent1 instead of + failing + +# v1.10.0 + +- add support for overriding the version number via the environment + variable SETUPTOOLS_SCM_PRETEND_VERSION + +- fix issue #63 by adding the `--match` parameter to the git describe + call and prepare the possibility of passing more options to scm + backends + +- fix issue #70 and #71 by introducing the parse keyword to specify + custom scm parsing, it's an expert feature, use with caution + + this change also introduces the setuptools_scm.parse_scm_fallback + entrypoint which can be used to register custom archive fallbacks + +# v1.9.0 + +- Add `relative_to` parameter to `get_version` function, fixes #44 per #45. + +# v1.8.0 + +- fix issue with setuptools wrong version warnings being printed to standard out. User is informed now by distutils-warnings. +- restructure root finding, we now reliably ignore outer scm and prefer PKG-INFO over scm, fixes #43 and #45 + +# v1.7.0 + +- correct the url to github thanks David Szotten +- enhance scm not found errors with a note on git tarballs thanks Markus +- add support for `write_to_template` + +# v1.6.0 + +- bail out early if the scm is missing + + this brings issues with git tarballs and older devpi-client releases + to light, before we would let the setup stay at version 0.0, now + there is a ValueError + +- properly raise errors on write_to misuse (thanks Te-jé Rodgers) + +# v1.5.5 + +- Fix bug on Python 2 on Windows when environment has unicode fields. + +# v1.5.4 + +- Fix bug on Python 2 when version is loaded from existing metadata. + +# v1.5.3 + +- #28: Fix decoding error when PKG-INFO contains non-ASCII. + +# v1.5.2 + +- add zip_safe flag + +# v1.5.1 + +- fix file access bug I missed in 1.5 + +# v1.5.0 + +- moved setuptools integration related code to own file +- support storing version strings into a module/text file using the `write_to` configuration parameter + +# v1.4.0 + +- proper handling for sdist +- fix file-finder failure from windows +- reshuffle docs + +# v1.3.0 + +- support setuptools easy_install egg creation details by hardwire-ing the version in the sdist + +# v1.2.0 + +- enhance self-use + +# v1.1.0 + +- enable self-use + +# v1.0.0 + +- documentation enhancements + +# v0.26 + +- rename to setuptools_scm +- split into package, add lots of entry points for extension +- pluggable version schemes + +# v0.25 + +- fix pep440 support this reshuffles the complete code for version guessing + +# v0.24 + +- don't drop dirty flag on node finding +- fix distance for dirty flagged versions +- use dashes for time again, its normalisation with setuptools +- remove the own version attribute, it was too fragile to test for +- include file finding +- handle edge cases around dirty tagged versions + +# v0.23 + +- windows compatibility fix (thanks stefan) drop samefile since it`s missing in some python2 versions on windows +- add tests to the source tarballs + +# v0.22 + +- windows compatibility fix (thanks stefan) use samefile since it does path normalisation + +# v0.21 + +- fix the own version attribute (thanks stefan) + +# v0.20 + +- fix issue 11: always take git describe long format to avoid the source of the ambiguity +- fix issue 12: add a `__version__` attribute via pkginfo + +# v0.19 + +- configurable next version guessing +- fix distance guessing (thanks stefan) diff --git a/CHANGELOG.rst b/CHANGELOG.rst deleted file mode 100644 index 251ee8d..0000000 --- a/CHANGELOG.rst +++ /dev/null @@ -1,504 +0,0 @@ -v4.1.2 -======= - -* disallow git tags without dots by default again - #449 - -v4.1.1 -======= - -* drop jaraco.windows from pyproject.toml, allows for wheel builds on python2 - - -v4.1.0 -======= - -* include python 3.9 via the deadsnakes action -* return release_branch_semver scheme (it got dropped in a bad rebase) -* undo the devendoring of the samefile backport for python2.7 on windows -* re-enable the building of universal wheels -* fix handling of missing git/hg on python2.7 (python 3 exceptions where used) -* correct the tox flake8 invocation -* trigger builds on tags again - -v4.0.0 -====== - -* Add ``parentdir_project_version`` to support installs from GitHub release - tarballs. -* use Coordinated Universal Time (UTC) -* switch to github actions for ci -* fix documentation for ``tag_regex`` and add support for single digit versions -* document handling of enterprise distros with unsupported setuptools versions #312 -* switch to declarative metadata -* drop the internal copy of samefile and use a dependency on jaraco.windows on legacy systems -* select git tags based on the presence of numbers instead of dots -* enable getting a version form a parent folder prefix -* add release-branch-semver version scheme -* make global configuration available to version metadata -* drop official support for python 3.4 - -v3.5.0 -====== - -* add ``no-local-version`` local scheme and improve documentation for schemes - -v3.4.4 -====== - -* fix #403: also sort out resource warnings when dealing with git file finding - -v3.4.3 -====== - -* fix #399: ensure the git file finder terminates subprocess after reading archive - -v3.4.2 -====== - -* fix #395: correctly transfer tag regex in the Configuration constructor -* rollback --first-parent for git describe as it turns out to be a regression for some users - -v3.4.1 -====== - -* pull in #377 to fix #374: correctly set up the default version scheme for pyproject usage. - this bugfix got missed when ruushing the release. - -v3.4.0 -====== - -* fix #181 - add support for projects built under setuptools declarative config - by way of the setuptools.finalize_distribution_options hook in Setuptools 42. - -* fix #305 - ensure the git file finder closes filedescriptors even when errors happen - -* fix #381 - clean out env vars from the git hook system to ensure correct function from within - -* modernize docs wrt importlib.metadata - -*edited* - -* use --first-parent for git describe - -v3.3.3 -====== - -* add eggs for python3.7 and 3.8 to the deploy - -v3.3.2 -====== - - -* fix #335 - fix python3.8 support and add builds for up to python3.8 - -v3.3.1 -====== - -* fix #333 (regression from #198) - use a specific fallback root when calling fallbacks. Remove old - hack that resets the root when fallback entrypoints are present. - -v3.3.0 -====== - -* fix #198 by adding the ``fallback_version`` option, which sets the version to be used when everything else fails. - -v3.2.0 -====== - -* fix #303 and #283 by adding the option ``git_describe_command`` to allow the user to control the -way that `git describe` is called. - -v3.1.0 -======= - -* fix #297 - correct the invocation in version_from_scm and deprecate it as its exposed by accident -* fix #298 - handle git file listing on empty repositories -* fix #268 - deprecate ScmVersion.extra - - -v3.0.6 -====== -* fix #295 - correctly handle selfinstall from tarballs - -v3.0.5 -====== - -* fix #292 - match leading ``V`` character as well - - https://www.python.org/dev/peps/pep-0440/#preceding-v-character - -v3.0.4 -======= - -* rerelease of 3.0.3 after fixing the release process - -v3.0.3 (pulled from pypi due to a packaging issue) -====== - -* fix #286 - duo an oversight a helper functio nwas returning a generator instead of a list - - -v3.0.2 -====== - -* fix a regression from tag parsing - support for multi-dashed prefixes - #284 - - -v3.0.1 -======= - -* fix a regression in setuptools_scm.git.parse - reorder arguments so the positional invocation from before works as expected #281 - -v3.0.0 -======= - -* introduce pre-commit and use black -* print the origin module to help testing -* switch to src layout (breaking change) -* no longer alias tag and parsed_version in order to support understanding a version parse failure -* require parse results to be ScmVersion or None (breaking change) -* fix #266 by requiring the prefix word to be a word again - (breaking change as the bug allowed arbitrary prefixes while the original feature only allowed words") -* introduce a internal config object to allow the configruation fo tag parsing and prefixes - (thanks to @punkadiddle for introducing it and passing it trough) - -v2.1.0 -====== - -* enhance docs for sphinx usage -* add symlink support to file finder for git #247 - (thanks Stéphane Bidoul) -* enhance tests handling win32 - (thanks Stéphane Bidoul) - -v2.0.0 -======== - -* fix #237 - correct imports in code examples -* improve mercurial commit detection (thanks Aaron) -* breaking change: remove support for setuptools before parsed versions -* reintroduce manifest as the travis deploy cant use the file finder -* reconfigure flake8 for future compatibility with black -* introduce support for branch name in version metadata and support a opt-in simplified semver version scheme - -v1.17.0 -======== - -* fix regression in git support - use a function to ensure it works in egg isntalled mode -* actually fail if file finding fails in order to see broken setups instead of generating broken dists - - (thanks Mehdi ABAAKOUK for both) - - -v1.16.2 -======== - -* fix regression in handling git export ignores - (thanks Mehdi ABAAKOUK) - -v1.16.1 -======= - -* fix regression in support for old setuptools versions - (thanks Marco Clemencic) - - -v1.16.0 -======= - -* drop support for eol python versions -* #214 - fix missuse in surogate-escape api -* add the node-and-timestamp local version sheme -* respect git export ignores -* avoid shlex.split on windows -* fix #218 - better handling of mercurial edge-cases with tag commits - being considered as the tagged commit -* fix #223 - remove the dependency on the interal SetupttoolsVersion - as it was removed after long-standing deprecation - -v1.15.7 -====== - -* Fix #174 with #207: Re-use samefile backport as developed in - jaraco.windows, and only use the backport where samefile is - not available. - -v1.15.6 -======= - -* fix #171 by unpinning the py version to allow a fixed one to get installed - -v1.15.5 -======= - -* fix #167 by correctly respecting preformatted version metadata - from PKG-INFO/EGG-INFO - -v1.15.4 -======= - -* fix issue #164: iterate all found entry points to avoid erros when pip remakes egg-info -* enhance self-use to enable pip install from github again - -v1.15.3 -======= - -* bring back correctly getting our version in the own sdist, finalizes #114 -* fix issue #150: strip local components of tags - -v1.15.2 -======= - -* fix issue #128: return None when a scm specific parse fails in a worktree to ease parse reuse - - -v1.15.1 -======= - -* fix issue #126: the local part of any tags is discarded - when guessing new versions -* minor performance optimization by doing fewer git calls - in the usual cases - - -v1.15.0 -======= - -* more sophisticated ignoring of mercurial tag commits - when considering distance in commits - (thanks Petre Mierlutiu) -* fix issue #114: stop trying to be smart for the sdist - and ensure its always correctly usign itself -* update trove classifiers -* fix issue #84: document using the installed package metadata for sphinx -* fix issue #81: fail more gracious when git/hg are missing -* address issue #93: provide an experimental api to customize behaviour on shallow git repos - a custom parse function may pick pre parse actions to do when using git - - -v1.14.1 -======= - -* fix #109: when detecting a dirty git workdir - don't consider untracked file - (this was a regression due to #86 in v1.13.1) -* consider the distance 0 when the git node is unknown - (happens when you haven't commited anything) - -v1.14.0 -======= - -* publish bdist_egg for python 2.6, 2.7 and 3.3-3.5 -* fix issue #107 - dont use node if it is None - -v1.13.1 -======= - -* fix issue #86 - detect dirty git workdir without tags - -v1.13.0 -======= - -* fix regression caused by the fix of #101 - * assert types for version dumping - * strictly pass all versions trough parsed version metadata - -v1.12.0 -======= - -* fix issue #97 - add support for mercurial plugins -* fix issue #101 - write version cache even for pretend version - (thanks anarcat for reporting and fixing) - -v1.11.1 -======== - -* fix issue #88 - better docs for sphinx usage (thanks Jason) -* fix issue #89 - use normpath to deal with windows - (thanks Te-jé Rodgers for reporting and fixing) - -v1.11.0 -======= - -* always run tag_to_version so in order to handle prefixes on old setuptools - (thanks to Brian May) -* drop support for python 3.2 -* extend the error message on missing scm metadata - (thanks Markus Unterwaditzer) -* fix bug when using callable version_scheme - (thanks Esben Haabendal) - -v1.10.1 -======= - -* fix issue #73 - in hg pre commit merge, consider parent1 instead of failing - -v1.10.0 -======= - -* add support for overriding the version number via the - environment variable SETUPTOOLS_SCM_PRETEND_VERSION - -* fix isssue #63 by adding the --match parameter to the git describe call - and prepare the possibility of passing more options to scm backends - -* fix issue #70 and #71 by introducing the parse keyword - to specify custom scm parsing, its an expert feature, - use with caution - - this change also introduces the setuptools_scm.parse_scm_fallback - entrypoint which can be used to register custom archive fallbacks - - -v1.9.0 -====== - -* Add :code:`relative_to` parameter to :code:`get_version` function; - fixes #44 per #45. - -v1.8.0 -====== - -* fix issue with setuptools wrong version warnings being printed to standard - out. User is informed now by distutils-warnings. -* restructure root finding, we now reliably ignore outer scm - and prefer PKG-INFO over scm, fixes #43 and #45 - -v1.7.0 -====== - -* correct the url to github - thanks David Szotten -* enhance scm not found errors with a note on git tarballs - thanks Markus -* add support for :code:`write_to_template` - -v1.6.0 -====== - -* bail out early if the scm is missing - - this brings issues with git tarballs and - older devpi-client releases to light, - before we would let the setup stay at version 0.0, - now there is a ValueError - -* propperly raise errors on write_to missuse (thanks Te-jé Rodgers) - -v1.5.5 -====== - -* Fix bug on Python 2 on Windows when environment has unicode fields. - -v1.5.4 -====== - -* Fix bug on Python 2 when version is loaded from existing metadata. - -v1.5.3 -====== - -* #28: Fix decoding error when PKG-INFO contains non-ASCII. - -v1.5.2 -====== - -* add zip_safe flag - -v1.5.1 -====== - -* fix file access bug i missed in 1.5 - -v1.5.0 -====== - -* moved setuptools integration related code to own file -* support storing version strings into a module/text file - using the :code:`write_to` coniguration parameter - -v1.4.0 -====== - -* propper handling for sdist -* fix file-finder failure from windows -* resuffle docs - -v1.3.0 -====== - -* support setuptools easy_install egg creation details - by hardwireing the version in the sdist - -v1.2.0 -====== - -* enhance self-use - -v1.1.0 -====== - -* enable self-use - -v1.0.0 -====== - -* documentation enhancements - -v0.26 -===== - -* rename to setuptools_scm -* split into package, add lots of entry points for extension -* pluggable version schemes - -v0.25 -===== - -* fix pep440 support - this reshuffles the complete code for version guessing - -v0.24 -===== - -* dont drop dirty flag on node finding -* fix distance for dirty flagged versions -* use dashes for time again, - its normalisation with setuptools -* remove the own version attribute, - it was too fragile to test for -* include file finding -* handle edge cases around dirty tagged versions - -v0.23 -===== - -* windows compatibility fix (thanks stefan) - drop samefile since its missing in - some python2 versions on windows -* add tests to the source tarballs - - -v0.22 -===== - -* windows compatibility fix (thanks stefan) - use samefile since it does path normalisation - -v0.21 -===== - -* fix the own version attribute (thanks stefan) - -v0.20 -===== - -* fix issue 11: always take git describe long format - to avoid the source of the ambiguity -* fix issue 12: add a __version__ attribute via pkginfo - -v0.19 -===== - -* configurable next version guessing -* fix distance guessing (thanks stefan) diff --git a/MANIFEST.in b/MANIFEST.in index 4bbd88d..6b9e320 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,10 +1,25 @@ exclude *.nix -exclude .travis.yaml exclude .pre-commit-config.yaml +exclude changelog.d/* +exclude .git_archival.txt +exclude .readthedocs.yaml include *.py include testing/*.py include tox.ini include *.rst include LICENSE include *.toml +include mypy.ini +include testing/Dockerfile.* +include src/setuptools_scm/.git_archival.txt +include README.md +include CHANGELOG.md + + recursive-include testing *.bash +prune nextgen + +recursive-include docs *.md +include docs/examples/version_scheme_code/*.py +include docs/examples/version_scheme_code/*.toml +include mkdocs.yml diff --git a/PKG-INFO b/PKG-INFO index 1f3b082..9ad3e8d 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,615 +1,164 @@ Metadata-Version: 2.1 -Name: setuptools_scm -Version: 4.1.2 +Name: setuptools-scm +Version: 8.0.4 Summary: the blessed package to manage your versions by scm tags -Home-page: https://github.com/pypa/setuptools_scm/ -Author: Ronny Pfannschmidt -Author-email: opensource@ronnypfannschmidt.de -License: MIT -Description: setuptools_scm - =============== - - ``setuptools_scm`` handles managing your Python package versions - in SCM metadata instead of declaring them as the version argument - or in a SCM managed file. - - Additionally ``setuptools_scm`` provides setuptools with a list of files that are managed by the SCM - (i.e. it automatically adds all of the SCM-managed files to the sdist). - Unwanted files must be excluded by discarding them via ``MANIFEST.in``. - - .. image:: https://travis-ci.org/pypa/setuptools_scm.svg?branch=master - :target: https://travis-ci.org/pypa/setuptools_scm - - .. image:: https://tidelift.com/badges/package/pypi/setuptools-scm - :target: https://tidelift.com/subscription/pkg/pypi-setuptools-scm?utm_source=pypi-setuptools-scm&utm_medium=readme - - - ``pyproject.toml`` usage - ------------------------ - - The preferred way to configure ``setuptools_scm`` is to author - settings in a ``tool.setuptools_scm`` section of ``pyproject.toml``. - - This feature requires Setuptools 42 or later, released in Nov, 2019. - If your project needs to support build from sdist on older versions - of Setuptools, you will need to also implement the ``setup.py usage`` - for those legacy environments. - - First, ensure that ``setuptools_scm`` is present during the project's - built step by specifying it as one of the build requirements. - - .. code:: toml - - # pyproject.toml - [build-system] - requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] - - Note that the ``toml`` extra must be supplied. - - That will be sufficient to require ``setuptools_scm`` for projects - that support PEP 518 (`pip `_ and - `pep517 `_). Many tools, - especially those that invoke ``setup.py`` for any reason, may - continue to rely on ``setup_requires``. For maximum compatibility - with those uses, consider also including a ``setup_requires`` directive - (described below in ``setup.py usage`` and ``setup.cfg``). - - To enable version inference, add this section to your pyproject.toml: - - .. code:: toml - - # pyproject.toml - [tool.setuptools_scm] - - Including this section is comparable to supplying - ``use_scm_version=True`` in ``setup.py``. Additionally, - include arbitrary keyword arguments in that section - to be supplied to ``get_version()``. For example: - - .. code:: toml - - # pyproject.toml - - [tool.setuptools_scm] - write_to = "pkg/version.py" - - - ``setup.py`` usage - ------------------ - - The following settings are considered legacy behavior and - superseded by the ``pyproject.toml`` usage, but for maximal - compatibility, projects may also supply the configuration in - this older form. - - To use ``setuptools_scm`` just modify your project's ``setup.py`` file - like this: - - * Add ``setuptools_scm`` to the ``setup_requires`` parameter. - * Add the ``use_scm_version`` parameter and set it to ``True``. - - For example: - - .. code:: python - - from setuptools import setup - setup( - ..., - use_scm_version=True, - setup_requires=['setuptools_scm'], - ..., - ) - - Arguments to ``get_version()`` (see below) may be passed as a dictionary to - ``use_scm_version``. For example: - - .. code:: python - - from setuptools import setup - setup( - ..., - use_scm_version = { - "root": "..", - "relative_to": __file__, - "local_scheme": "node-and-timestamp" - }, - setup_requires=['setuptools_scm'], - ..., - ) - - You can confirm the version number locally via ``setup.py``: - - .. code-block:: shell - - $ python setup.py --version - - .. note:: - - If you see unusual version numbers for packages but ``python setup.py - --version`` reports the expected version number, ensure ``[egg_info]`` is - not defined in ``setup.cfg``. - - - ``setup.cfg`` usage - ------------------- - - If using `setuptools 30.3.0 - `_ - or greater, you can store ``setup_requires`` configuration in ``setup.cfg``. - However, ``use_scm_version`` must still be placed in ``setup.py``. For example: - - .. code:: python - - # setup.py - from setuptools import setup - setup( - use_scm_version=True, - ) - - .. code:: ini - - # setup.cfg - [metadata] - ... - - [options] - setup_requires = - setuptools_scm - ... - - .. important:: - - Ensure neither the ``[metadata]`` ``version`` option nor the ``[egg_info]`` - section are defined, as these will interfere with ``setuptools_scm``. - - You may also need to define a ``pyproject.toml`` file (`PEP-0518 - `_) to ensure you have the required - version of ``setuptools``: - - .. code:: ini - - # pyproject.toml - [build-system] - requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] - - For more information, refer to the `setuptools issue #1002 - `_. - - - Programmatic usage - ------------------ - - In order to use ``setuptools_scm`` from code that is one directory deeper - than the project's root, you can use: - - .. code:: python - - from setuptools_scm import get_version - version = get_version(root='..', relative_to=__file__) - - See `setup.py Usage`_ above for how to use this within ``setup.py``. - - - Retrieving package version at runtime - ------------------------------------- - - If you have opted not to hardcode the version number inside the package, - you can retrieve it at runtime from PEP-0566_ metadata using - ``importlib.metadata`` from the standard library - or the `importlib_metadata`_ backport: - - .. code:: python - - from importlib.metadata import version, PackageNotFoundError - - try: - __version__ = version(__name__) - except PackageNotFoundError: - # package is not installed - pass - - Alternatively, you can use ``pkg_resources`` which is included in - ``setuptools``: - - .. code:: python - - from pkg_resources import get_distribution, DistributionNotFound - - try: - __version__ = get_distribution(__name__).version - except DistributionNotFound: - # package is not installed - pass - - This does place a runtime dependency on ``setuptools``. - - .. _PEP-0566: https://www.python.org/dev/peps/pep-0566/ - .. _importlib_metadata: https://pypi.org/project/importlib-metadata/ - - - Usage from Sphinx - ----------------- - - It is discouraged to use ``setuptools_scm`` from Sphinx itself, - instead use ``pkg_resources`` after editable/real installation: - - .. code:: python - - # contents of docs/conf.py - from pkg_resources import get_distribution - release = get_distribution('myproject').version - # for example take major/minor - version = '.'.join(release.split('.')[:2]) - - The underlying reason is, that services like *Read the Docs* sometimes change - the working directory for good reasons and using the installed metadata - prevents using needless volatile data there. - - Notable Plugins - ---------------- - - `setuptools_scm_git_archive `_ - Provides partial support for obtaining versions from git archives that - belong to tagged versions. The only reason for not including it in - ``setuptools_scm`` itself is Git/GitHub not supporting sufficient metadata - for untagged/followup commits, which is preventing a consistent UX. - - - Default versioning scheme - -------------------------- - - In the standard configuration ``setuptools_scm`` takes a look at three things: - - 1. latest tag (with a version number) - 2. the distance to this tag (e.g. number of revisions since latest tag) - 3. workdir state (e.g. uncommitted changes since latest tag) - - and uses roughly the following logic to render the version: - - no distance and clean: - ``{tag}`` - distance and clean: - ``{next_version}.dev{distance}+{scm letter}{revision hash}`` - no distance and not clean: - ``{tag}+dYYYYMMDD`` - distance and not clean: - ``{next_version}.dev{distance}+{scm letter}{revision hash}.dYYYYMMDD`` - - The next version is calculated by adding ``1`` to the last numeric component of - the tag. - - For Git projects, the version relies on `git describe `_, - so you will see an additional ``g`` prepended to the ``{revision hash}``. - - Semantic Versioning (SemVer) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Due to the default behavior it's necessary to always include a - patch version (the ``3`` in ``1.2.3``), or else the automatic guessing - will increment the wrong part of the SemVer (e.g. tag ``2.0`` results in - ``2.1.devX`` instead of ``2.0.1.devX``). So please make sure to tag - accordingly. - - .. note:: - - Future versions of ``setuptools_scm`` will switch to `SemVer - `_ by default hiding the the old behavior as an - configurable option. - - - Builtin mechanisms for obtaining version numbers - -------------------------------------------------- - - 1. the SCM itself (git/hg) - 2. ``.hg_archival`` files (mercurial archives) - 3. ``PKG-INFO`` - - .. note:: - - Git archives are not supported due to Git shortcomings - - - File finders hook makes most of MANIFEST.in unnecessary - ------------------------------------------------------- - - ``setuptools_scm`` implements a `file_finders - `_ - entry point which returns all files tracked by your SCM. This eliminates - the need for a manually constructed ``MANIFEST.in`` in most cases where this - would be required when not using ``setuptools_scm``, namely: - - * To ensure all relevant files are packaged when running the ``sdist`` command. - - * When using `include_package_data `_ - to include package data as part of the ``build`` or ``bdist_wheel``. - - ``MANIFEST.in`` may still be used: anything defined there overrides the hook. - This is mostly useful to exclude files tracked in your SCM from packages, - although in principle it can be used to explicitly include non-tracked files - too. - - - Configuration parameters - ------------------------ - - In order to configure the way ``use_scm_version`` works you can provide - a mapping with options instead of a boolean value. - - The currently supported configuration keys are: - - :root: - Relative path to cwd, used for finding the SCM root; defaults to ``.`` - - :version_scheme: - Configures how the local version number is constructed; either an - entrypoint name or a callable. - - :local_scheme: - Configures how the local component of the version is constructed; either an - entrypoint name or a callable. - - :write_to: - A path to a file that gets replaced with a file containing the current - version. It is ideal for creating a ``version.py`` file within the - package, typically used to avoid using `pkg_resources.get_distribution` - (which adds some overhead). - - .. warning:: - - Only files with :code:`.py` and :code:`.txt` extensions have builtin - templates, for other file types it is necessary to provide - :code:`write_to_template`. - - :write_to_template: - A newstyle format string that is given the current version as - the ``version`` keyword argument for formatting. - - :relative_to: - A file from which the root can be resolved. - Typically called by a script or module that is not in the root of the - repository to point ``setuptools_scm`` at the root of the repository by - supplying ``__file__``. - - :tag_regex: - A Python regex string to extract the version part from any SCM tag. - The regex needs to contain either a single match group, or a group - named ``version``, that captures the actual version information. - - Defaults to the value of ``setuptools_scm.config.DEFAULT_TAG_REGEX`` - (see `config.py `_). - - :parentdir_prefix_version: - If the normal methods for detecting the version (SCM version, - sdist metadata) fail, and the parent directory name starts with - ``parentdir_prefix_version``, then this prefix is stripped and the rest of - the parent directory name is matched with ``tag_regex`` to get a version - string. If this parameter is unset (the default), then this fallback is - not used. - - This is intended to cover GitHub's "release tarballs", which extract into - directories named ``projectname-tag/`` (in which case - ``parentdir_prefix_version`` can be set e.g. to ``projectname-``). - - :fallback_version: - A version string that will be used if no other method for detecting the - version worked (e.g., when using a tarball with no metadata). If this is - unset (the default), setuptools_scm will error if it fails to detect the - version. - - :parse: - A function that will be used instead of the discovered SCM for parsing the - version. - Use with caution, this is a function for advanced use, and you should be - familiar with the ``setuptools_scm`` internals to use it. - - :git_describe_command: - This command will be used instead the default ``git describe`` command. - Use with caution, this is a function for advanced use, and you should be - familiar with the ``setuptools_scm`` internals to use it. - - Defaults to the value set by ``setuptools_scm.git.DEFAULT_DESCRIBE`` - (see `git.py `_). - - To use ``setuptools_scm`` in other Python code you can use the ``get_version`` - function: - - .. code:: python - - from setuptools_scm import get_version - my_version = get_version() - - It optionally accepts the keys of the ``use_scm_version`` parameter as - keyword arguments. - - Example configuration in ``setup.py`` format: - - .. code:: python - - from setuptools import setup - - setup( - use_scm_version={ - 'write_to': 'version.py', - 'write_to_template': '__version__ = "{version}"', - 'tag_regex': r'^(?Pv)?(?P[^\+]+)(?P.*)?$', - } - ) - - Environment variables - --------------------- - - :SETUPTOOLS_SCM_PRETEND_VERSION: - when defined and not empty, - its used as the primary source for the version number - in which case it will be a unparsed string - - :SETUPTOOLS_SCM_DEBUG: - when defined and not empty, - a lot of debug information will be printed as part of ``setuptools_scm`` - operating - - Extending setuptools_scm - ------------------------ - - ``setuptools_scm`` ships with a few ``setuptools`` entrypoints based hooks to - extend its default capabilities. - - Adding a new SCM - ~~~~~~~~~~~~~~~~ - - ``setuptools_scm`` provides two entrypoints for adding new SCMs: - - ``setuptools_scm.parse_scm`` - A function used to parse the metadata of the current workdir - using the name of the control directory/file of your SCM as the - entrypoint's name. E.g. for the built-in entrypoint for git the - entrypoint is named ``.git`` and references ``setuptools_scm.git:parse`` - - The return value MUST be a ``setuptools_scm.version.ScmVersion`` instance - created by the function ``setuptools_scm.version:meta``. - - ``setuptools_scm.files_command`` - Either a string containing a shell command that prints all SCM managed - files in its current working directory or a callable, that given a - pathname will return that list. - - Also use then name of your SCM control directory as name of the entrypoint. - - Version number construction - ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - ``setuptools_scm.version_scheme`` - Configures how the version number is constructed given a - ``setuptools_scm.version.ScmVersion`` instance and should return a string - representing the version. - - Available implementations: - - :guess-next-dev: Automatically guesses the next development version (default). - Guesses the upcoming release by incrementing the pre-release segment if present, - otherwise by incrementing the micro segment. Then appends :code:`.devN`. - :post-release: generates post release versions (adds :code:`.postN`) - :python-simplified-semver: Basic semantic versioning. Guesses the upcoming release - by incrementing the minor segment and setting the micro segment to zero if the - current branch contains the string ``'feature'``, otherwise by incrementing the - micro version. Then appends :code:`.devN`. Not compatible with pre-releases. - :release-branch-semver: Semantic versioning for projects with release branches. The - same as ``guess-next-dev`` (incrementing the pre-release or micro segment) if on - a release branch: a branch whose name (ignoring namespace) parses as a version - that matches the most recent tag up to the minor segment. Otherwise if on a - non-release branch, increments the minor segment and sets the micro segment to - zero, then appends :code:`.devN`. - - ``setuptools_scm.local_scheme`` - Configures how the local part of a version is rendered given a - ``setuptools_scm.version.ScmVersion`` instance and should return a string - representing the local version. - Dates and times are in Coordinated Universal Time (UTC), because as part - of the version, they should be location independent. - - Available implementations: - - :node-and-date: adds the node on dev versions and the date on dirty - workdir (default) - :node-and-timestamp: like ``node-and-date`` but with a timestamp of - the form ``{:%Y%m%d%H%M%S}`` instead - :dirty-tag: adds ``+dirty`` if the current workdir has changes - :no-local-version: omits local version, useful e.g. because pypi does - not support it - - - Importing in ``setup.py`` - ~~~~~~~~~~~~~~~~~~~~~~~~~ - - To support usage in ``setup.py`` passing a callable into ``use_scm_version`` - is supported. - - Within that callable, ``setuptools_scm`` is available for import. - The callable must return the configuration. - - - .. code:: python - - # content of setup.py - import setuptools - - def myversion(): - from setuptools_scm.version import get_local_dirty_tag - def clean_scheme(version): - return get_local_dirty_tag(version) if version.dirty else '+clean' - - return {'local_scheme': clean_scheme} - - setup( - ..., - use_scm_version=myversion, - ... - ) - - - Note on testing non-installed versions - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - While the general advice is to test against a installed version, - some environments require a test prior to install, - - .. code:: - - $ python setup.py egg_info - $ PYTHONPATH=$PWD:$PWD/src pytest - - - Interaction with Enterprise Distributions - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Some enterprise distributions like RHEL7 and others - ship rather old setuptools versions due to various release management details. - - On such distributions one might observe errors like: - - :code:``setuptools_scm.version.SetuptoolsOutdatedWarning: your setuptools is too old (<12)`` - - In those case its typically possible to build by using a sdist against ``setuptools_scm<2.0``. - As those old setuptools versions lack sensible types for versions, - modern setuptools_scm is unable to support them sensibly. - - In case the project you need to build can not be patched to either use old setuptools_scm, - its still possible to install a more recent version of setuptools in order to handle the build - and/or install the package by using wheels or eggs. - - - - - Code of Conduct - --------------- - - Everyone interacting in the ``setuptools_scm`` project's codebases, issue - trackers, chat rooms, and mailing lists is expected to follow the - `PyPA Code of Conduct`_. - - .. _PyPA Code of Conduct: https://www.pypa.io/en/latest/code-of-conduct/ - - Security Contact - ================ - - To report a security vulnerability, please use the - `Tidelift security contact `_. - Tidelift will coordinate the fix and disclosure. - -Platform: UNKNOWN +Author-email: Ronny Pfannschmidt +License: Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +Project-URL: documentation, https://setuptools-scm.readthedocs.io/ +Project-URL: repository, https://github.com/pypa/setuptools_scm/ Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 2.7 -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.5 -Classifier: Programming Language :: Python :: 3.6 -Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Software Development :: Version Control Classifier: Topic :: System :: Software Distribution Classifier: Topic :: Utilities -Requires-Python: !=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7 +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: packaging>=20 +Requires-Dist: setuptools +Requires-Dist: tomli>=1; python_version < "3.11" +Requires-Dist: typing-extensions +Provides-Extra: docs +Requires-Dist: entangled_cli[rich]; extra == "docs" +Requires-Dist: mkdocs; extra == "docs" +Requires-Dist: mkdocs-entangled-plugin; extra == "docs" +Requires-Dist: mkdocs-material; extra == "docs" +Requires-Dist: mkdocstrings[python]; extra == "docs" +Requires-Dist: pygments; extra == "docs" +Provides-Extra: rich +Requires-Dist: rich; extra == "rich" +Provides-Extra: test +Requires-Dist: build; extra == "test" +Requires-Dist: pytest; extra == "test" +Requires-Dist: rich; extra == "test" +Requires-Dist: wheel; extra == "test" Provides-Extra: toml + +# setuptools_scm +[![github ci](https://github.com/pypa/setuptools_scm/workflows/python%20tests+artifacts+release/badge.svg)](https://github.com/pypa/setuptools_scm/actions) +[![Documentation Status](https://readthedocs.org/projects/setuptools-scm/badge/?version=latest)](https://setuptools-scm.readthedocs.io/en/latest/?badge=latest) +[![tidelift](https://tidelift.com/badges/package/pypi/setuptools-scm) ](https://tidelift.com/subscription/pkg/pypi-setuptools-scm?utm_source=pypi-setuptools-scm&utm_medium=readme) + +## about + +[setuptools-scm] extracts Python package versions from `git` or +`hg` metadata instead of declaring them as the version argument +or in an SCM managed file. + +Additionally, [setuptools-scm] provides setuptools +with a list of files that are managed by the SCM
+(i.e. it automatically adds **all of** the SCM-managed files to the sdist).
+Unwanted files must be excluded via `MANIFEST.in`. + + +## `pyproject.toml` usage + +The preferred way to configure [setuptools-scm] is to author +settings in a `tool.setuptools_scm` section of `pyproject.toml`. + +This feature requires setuptools 60 or later. +First, ensure that [setuptools-scm] is present during the project's +build step by specifying it as one of the build requirements. + +```toml +[build-system] +requires = [ + "setuptools>=60", + "setuptools-scm>=8.0"] +``` + +That will be sufficient to require [setuptools-scm] for projects +that support [PEP 518] like [pip] and [build]. + +[pip]: https://pypi.org/project/pip +[build]: https://pypi.org/project/build +[PEP 518]: https://peps.python.org/pep-0518/ + + +To enable version inference, you need to set the version +dynamically in the `project` section of `pyproject.toml`: + +```toml title="pyproject.toml" +[project] +# version = "0.0.1" # Remove any existing version parameter. +dynamic = ["version"] +[tool.setuptools_scm] +``` + +Additionally, a version file can be written by specifying: + +```toml title="pyproject.toml" +[tool.setuptools_scm] +version_file = "pkg/_version.py" +``` + +Where `pkg` is the name of your package. + +If you need to confirm which version string is being generated or debug the configuration, +you can install [setuptools-scm] directly in your working environment and run: + +```console +$ python -m setuptools_scm +# To explore other options, try: +$ python -m setuptools_scm --help +``` + +For further configuration see the [documentation]. + +[setuptools-scm]: https://github.com/pypa/setuptools_scm +[documentation]: https://setuptools-scm.readthedocs.io/ + + +## Interaction with Enterprise Distributions + +Some enterprise distributions like RHEL7 +ship rather old setuptools versions. + +In those cases its typically possible to build by using an sdist against `setuptools_scm<2.0`. +As those old setuptools versions lack sensible types for versions, +modern [setuptools-scm] is unable to support them sensibly. + +It's strongly recommended to build a wheel artifact using modern Python and setuptools, +then installing the artifact instead of trying to run against old setuptools versions. + + +## Code of Conduct + + +Everyone interacting in the [setuptools-scm] project's codebases, issue +trackers, chat rooms, and mailing lists is expected to follow the +[PSF Code of Conduct]. + +[PSF Code of Conduct]: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md + + +## Security Contact + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc6d8a3 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# setuptools_scm +[![github ci](https://github.com/pypa/setuptools_scm/workflows/python%20tests+artifacts+release/badge.svg)](https://github.com/pypa/setuptools_scm/actions) +[![Documentation Status](https://readthedocs.org/projects/setuptools-scm/badge/?version=latest)](https://setuptools-scm.readthedocs.io/en/latest/?badge=latest) +[![tidelift](https://tidelift.com/badges/package/pypi/setuptools-scm) ](https://tidelift.com/subscription/pkg/pypi-setuptools-scm?utm_source=pypi-setuptools-scm&utm_medium=readme) + +## about + +[setuptools-scm] extracts Python package versions from `git` or +`hg` metadata instead of declaring them as the version argument +or in an SCM managed file. + +Additionally, [setuptools-scm] provides setuptools +with a list of files that are managed by the SCM
+(i.e. it automatically adds **all of** the SCM-managed files to the sdist).
+Unwanted files must be excluded via `MANIFEST.in`. + + +## `pyproject.toml` usage + +The preferred way to configure [setuptools-scm] is to author +settings in a `tool.setuptools_scm` section of `pyproject.toml`. + +This feature requires setuptools 60 or later. +First, ensure that [setuptools-scm] is present during the project's +build step by specifying it as one of the build requirements. + +```toml +[build-system] +requires = [ + "setuptools>=60", + "setuptools-scm>=8.0"] +``` + +That will be sufficient to require [setuptools-scm] for projects +that support [PEP 518] like [pip] and [build]. + +[pip]: https://pypi.org/project/pip +[build]: https://pypi.org/project/build +[PEP 518]: https://peps.python.org/pep-0518/ + + +To enable version inference, you need to set the version +dynamically in the `project` section of `pyproject.toml`: + +```toml title="pyproject.toml" +[project] +# version = "0.0.1" # Remove any existing version parameter. +dynamic = ["version"] +[tool.setuptools_scm] +``` + +Additionally, a version file can be written by specifying: + +```toml title="pyproject.toml" +[tool.setuptools_scm] +version_file = "pkg/_version.py" +``` + +Where `pkg` is the name of your package. + +If you need to confirm which version string is being generated or debug the configuration, +you can install [setuptools-scm] directly in your working environment and run: + +```console +$ python -m setuptools_scm +# To explore other options, try: +$ python -m setuptools_scm --help +``` + +For further configuration see the [documentation]. + +[setuptools-scm]: https://github.com/pypa/setuptools_scm +[documentation]: https://setuptools-scm.readthedocs.io/ + + +## Interaction with Enterprise Distributions + +Some enterprise distributions like RHEL7 +ship rather old setuptools versions. + +In those cases its typically possible to build by using an sdist against `setuptools_scm<2.0`. +As those old setuptools versions lack sensible types for versions, +modern [setuptools-scm] is unable to support them sensibly. + +It's strongly recommended to build a wheel artifact using modern Python and setuptools, +then installing the artifact instead of trying to run against old setuptools versions. + + +## Code of Conduct + + +Everyone interacting in the [setuptools-scm] project's codebases, issue +trackers, chat rooms, and mailing lists is expected to follow the +[PSF Code of Conduct]. + +[PSF Code of Conduct]: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md + + +## Security Contact + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff --git a/README.rst b/README.rst deleted file mode 100644 index 074eb6c..0000000 --- a/README.rst +++ /dev/null @@ -1,589 +0,0 @@ -setuptools_scm -=============== - -``setuptools_scm`` handles managing your Python package versions -in SCM metadata instead of declaring them as the version argument -or in a SCM managed file. - -Additionally ``setuptools_scm`` provides setuptools with a list of files that are managed by the SCM -(i.e. it automatically adds all of the SCM-managed files to the sdist). -Unwanted files must be excluded by discarding them via ``MANIFEST.in``. - -.. image:: https://travis-ci.org/pypa/setuptools_scm.svg?branch=master - :target: https://travis-ci.org/pypa/setuptools_scm - -.. image:: https://tidelift.com/badges/package/pypi/setuptools-scm - :target: https://tidelift.com/subscription/pkg/pypi-setuptools-scm?utm_source=pypi-setuptools-scm&utm_medium=readme - - -``pyproject.toml`` usage ------------------------- - -The preferred way to configure ``setuptools_scm`` is to author -settings in a ``tool.setuptools_scm`` section of ``pyproject.toml``. - -This feature requires Setuptools 42 or later, released in Nov, 2019. -If your project needs to support build from sdist on older versions -of Setuptools, you will need to also implement the ``setup.py usage`` -for those legacy environments. - -First, ensure that ``setuptools_scm`` is present during the project's -built step by specifying it as one of the build requirements. - -.. code:: toml - - # pyproject.toml - [build-system] - requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] - -Note that the ``toml`` extra must be supplied. - -That will be sufficient to require ``setuptools_scm`` for projects -that support PEP 518 (`pip `_ and -`pep517 `_). Many tools, -especially those that invoke ``setup.py`` for any reason, may -continue to rely on ``setup_requires``. For maximum compatibility -with those uses, consider also including a ``setup_requires`` directive -(described below in ``setup.py usage`` and ``setup.cfg``). - -To enable version inference, add this section to your pyproject.toml: - -.. code:: toml - - # pyproject.toml - [tool.setuptools_scm] - -Including this section is comparable to supplying -``use_scm_version=True`` in ``setup.py``. Additionally, -include arbitrary keyword arguments in that section -to be supplied to ``get_version()``. For example: - -.. code:: toml - - # pyproject.toml - - [tool.setuptools_scm] - write_to = "pkg/version.py" - - -``setup.py`` usage ------------------- - -The following settings are considered legacy behavior and -superseded by the ``pyproject.toml`` usage, but for maximal -compatibility, projects may also supply the configuration in -this older form. - -To use ``setuptools_scm`` just modify your project's ``setup.py`` file -like this: - -* Add ``setuptools_scm`` to the ``setup_requires`` parameter. -* Add the ``use_scm_version`` parameter and set it to ``True``. - -For example: - -.. code:: python - - from setuptools import setup - setup( - ..., - use_scm_version=True, - setup_requires=['setuptools_scm'], - ..., - ) - -Arguments to ``get_version()`` (see below) may be passed as a dictionary to -``use_scm_version``. For example: - -.. code:: python - - from setuptools import setup - setup( - ..., - use_scm_version = { - "root": "..", - "relative_to": __file__, - "local_scheme": "node-and-timestamp" - }, - setup_requires=['setuptools_scm'], - ..., - ) - -You can confirm the version number locally via ``setup.py``: - -.. code-block:: shell - - $ python setup.py --version - -.. note:: - - If you see unusual version numbers for packages but ``python setup.py - --version`` reports the expected version number, ensure ``[egg_info]`` is - not defined in ``setup.cfg``. - - -``setup.cfg`` usage -------------------- - -If using `setuptools 30.3.0 -`_ -or greater, you can store ``setup_requires`` configuration in ``setup.cfg``. -However, ``use_scm_version`` must still be placed in ``setup.py``. For example: - -.. code:: python - - # setup.py - from setuptools import setup - setup( - use_scm_version=True, - ) - -.. code:: ini - - # setup.cfg - [metadata] - ... - - [options] - setup_requires = - setuptools_scm - ... - -.. important:: - - Ensure neither the ``[metadata]`` ``version`` option nor the ``[egg_info]`` - section are defined, as these will interfere with ``setuptools_scm``. - -You may also need to define a ``pyproject.toml`` file (`PEP-0518 -`_) to ensure you have the required -version of ``setuptools``: - -.. code:: ini - - # pyproject.toml - [build-system] - requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] - -For more information, refer to the `setuptools issue #1002 -`_. - - -Programmatic usage ------------------- - -In order to use ``setuptools_scm`` from code that is one directory deeper -than the project's root, you can use: - -.. code:: python - - from setuptools_scm import get_version - version = get_version(root='..', relative_to=__file__) - -See `setup.py Usage`_ above for how to use this within ``setup.py``. - - -Retrieving package version at runtime -------------------------------------- - -If you have opted not to hardcode the version number inside the package, -you can retrieve it at runtime from PEP-0566_ metadata using -``importlib.metadata`` from the standard library -or the `importlib_metadata`_ backport: - -.. code:: python - - from importlib.metadata import version, PackageNotFoundError - - try: - __version__ = version(__name__) - except PackageNotFoundError: - # package is not installed - pass - -Alternatively, you can use ``pkg_resources`` which is included in -``setuptools``: - -.. code:: python - - from pkg_resources import get_distribution, DistributionNotFound - - try: - __version__ = get_distribution(__name__).version - except DistributionNotFound: - # package is not installed - pass - -This does place a runtime dependency on ``setuptools``. - -.. _PEP-0566: https://www.python.org/dev/peps/pep-0566/ -.. _importlib_metadata: https://pypi.org/project/importlib-metadata/ - - -Usage from Sphinx ------------------ - -It is discouraged to use ``setuptools_scm`` from Sphinx itself, -instead use ``pkg_resources`` after editable/real installation: - -.. code:: python - - # contents of docs/conf.py - from pkg_resources import get_distribution - release = get_distribution('myproject').version - # for example take major/minor - version = '.'.join(release.split('.')[:2]) - -The underlying reason is, that services like *Read the Docs* sometimes change -the working directory for good reasons and using the installed metadata -prevents using needless volatile data there. - -Notable Plugins ----------------- - -`setuptools_scm_git_archive `_ - Provides partial support for obtaining versions from git archives that - belong to tagged versions. The only reason for not including it in - ``setuptools_scm`` itself is Git/GitHub not supporting sufficient metadata - for untagged/followup commits, which is preventing a consistent UX. - - -Default versioning scheme --------------------------- - -In the standard configuration ``setuptools_scm`` takes a look at three things: - -1. latest tag (with a version number) -2. the distance to this tag (e.g. number of revisions since latest tag) -3. workdir state (e.g. uncommitted changes since latest tag) - -and uses roughly the following logic to render the version: - -no distance and clean: - ``{tag}`` -distance and clean: - ``{next_version}.dev{distance}+{scm letter}{revision hash}`` -no distance and not clean: - ``{tag}+dYYYYMMDD`` -distance and not clean: - ``{next_version}.dev{distance}+{scm letter}{revision hash}.dYYYYMMDD`` - -The next version is calculated by adding ``1`` to the last numeric component of -the tag. - -For Git projects, the version relies on `git describe `_, -so you will see an additional ``g`` prepended to the ``{revision hash}``. - -Semantic Versioning (SemVer) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Due to the default behavior it's necessary to always include a -patch version (the ``3`` in ``1.2.3``), or else the automatic guessing -will increment the wrong part of the SemVer (e.g. tag ``2.0`` results in -``2.1.devX`` instead of ``2.0.1.devX``). So please make sure to tag -accordingly. - -.. note:: - - Future versions of ``setuptools_scm`` will switch to `SemVer - `_ by default hiding the the old behavior as an - configurable option. - - -Builtin mechanisms for obtaining version numbers --------------------------------------------------- - -1. the SCM itself (git/hg) -2. ``.hg_archival`` files (mercurial archives) -3. ``PKG-INFO`` - -.. note:: - - Git archives are not supported due to Git shortcomings - - -File finders hook makes most of MANIFEST.in unnecessary -------------------------------------------------------- - -``setuptools_scm`` implements a `file_finders -`_ -entry point which returns all files tracked by your SCM. This eliminates -the need for a manually constructed ``MANIFEST.in`` in most cases where this -would be required when not using ``setuptools_scm``, namely: - -* To ensure all relevant files are packaged when running the ``sdist`` command. - -* When using `include_package_data `_ - to include package data as part of the ``build`` or ``bdist_wheel``. - -``MANIFEST.in`` may still be used: anything defined there overrides the hook. -This is mostly useful to exclude files tracked in your SCM from packages, -although in principle it can be used to explicitly include non-tracked files -too. - - -Configuration parameters ------------------------- - -In order to configure the way ``use_scm_version`` works you can provide -a mapping with options instead of a boolean value. - -The currently supported configuration keys are: - -:root: - Relative path to cwd, used for finding the SCM root; defaults to ``.`` - -:version_scheme: - Configures how the local version number is constructed; either an - entrypoint name or a callable. - -:local_scheme: - Configures how the local component of the version is constructed; either an - entrypoint name or a callable. - -:write_to: - A path to a file that gets replaced with a file containing the current - version. It is ideal for creating a ``version.py`` file within the - package, typically used to avoid using `pkg_resources.get_distribution` - (which adds some overhead). - - .. warning:: - - Only files with :code:`.py` and :code:`.txt` extensions have builtin - templates, for other file types it is necessary to provide - :code:`write_to_template`. - -:write_to_template: - A newstyle format string that is given the current version as - the ``version`` keyword argument for formatting. - -:relative_to: - A file from which the root can be resolved. - Typically called by a script or module that is not in the root of the - repository to point ``setuptools_scm`` at the root of the repository by - supplying ``__file__``. - -:tag_regex: - A Python regex string to extract the version part from any SCM tag. - The regex needs to contain either a single match group, or a group - named ``version``, that captures the actual version information. - - Defaults to the value of ``setuptools_scm.config.DEFAULT_TAG_REGEX`` - (see `config.py `_). - -:parentdir_prefix_version: - If the normal methods for detecting the version (SCM version, - sdist metadata) fail, and the parent directory name starts with - ``parentdir_prefix_version``, then this prefix is stripped and the rest of - the parent directory name is matched with ``tag_regex`` to get a version - string. If this parameter is unset (the default), then this fallback is - not used. - - This is intended to cover GitHub's "release tarballs", which extract into - directories named ``projectname-tag/`` (in which case - ``parentdir_prefix_version`` can be set e.g. to ``projectname-``). - -:fallback_version: - A version string that will be used if no other method for detecting the - version worked (e.g., when using a tarball with no metadata). If this is - unset (the default), setuptools_scm will error if it fails to detect the - version. - -:parse: - A function that will be used instead of the discovered SCM for parsing the - version. - Use with caution, this is a function for advanced use, and you should be - familiar with the ``setuptools_scm`` internals to use it. - -:git_describe_command: - This command will be used instead the default ``git describe`` command. - Use with caution, this is a function for advanced use, and you should be - familiar with the ``setuptools_scm`` internals to use it. - - Defaults to the value set by ``setuptools_scm.git.DEFAULT_DESCRIBE`` - (see `git.py `_). - -To use ``setuptools_scm`` in other Python code you can use the ``get_version`` -function: - -.. code:: python - - from setuptools_scm import get_version - my_version = get_version() - -It optionally accepts the keys of the ``use_scm_version`` parameter as -keyword arguments. - -Example configuration in ``setup.py`` format: - -.. code:: python - - from setuptools import setup - - setup( - use_scm_version={ - 'write_to': 'version.py', - 'write_to_template': '__version__ = "{version}"', - 'tag_regex': r'^(?Pv)?(?P[^\+]+)(?P.*)?$', - } - ) - -Environment variables ---------------------- - -:SETUPTOOLS_SCM_PRETEND_VERSION: - when defined and not empty, - its used as the primary source for the version number - in which case it will be a unparsed string - -:SETUPTOOLS_SCM_DEBUG: - when defined and not empty, - a lot of debug information will be printed as part of ``setuptools_scm`` - operating - -Extending setuptools_scm ------------------------- - -``setuptools_scm`` ships with a few ``setuptools`` entrypoints based hooks to -extend its default capabilities. - -Adding a new SCM -~~~~~~~~~~~~~~~~ - -``setuptools_scm`` provides two entrypoints for adding new SCMs: - -``setuptools_scm.parse_scm`` - A function used to parse the metadata of the current workdir - using the name of the control directory/file of your SCM as the - entrypoint's name. E.g. for the built-in entrypoint for git the - entrypoint is named ``.git`` and references ``setuptools_scm.git:parse`` - - The return value MUST be a ``setuptools_scm.version.ScmVersion`` instance - created by the function ``setuptools_scm.version:meta``. - -``setuptools_scm.files_command`` - Either a string containing a shell command that prints all SCM managed - files in its current working directory or a callable, that given a - pathname will return that list. - - Also use then name of your SCM control directory as name of the entrypoint. - -Version number construction -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``setuptools_scm.version_scheme`` - Configures how the version number is constructed given a - ``setuptools_scm.version.ScmVersion`` instance and should return a string - representing the version. - - Available implementations: - - :guess-next-dev: Automatically guesses the next development version (default). - Guesses the upcoming release by incrementing the pre-release segment if present, - otherwise by incrementing the micro segment. Then appends :code:`.devN`. - :post-release: generates post release versions (adds :code:`.postN`) - :python-simplified-semver: Basic semantic versioning. Guesses the upcoming release - by incrementing the minor segment and setting the micro segment to zero if the - current branch contains the string ``'feature'``, otherwise by incrementing the - micro version. Then appends :code:`.devN`. Not compatible with pre-releases. - :release-branch-semver: Semantic versioning for projects with release branches. The - same as ``guess-next-dev`` (incrementing the pre-release or micro segment) if on - a release branch: a branch whose name (ignoring namespace) parses as a version - that matches the most recent tag up to the minor segment. Otherwise if on a - non-release branch, increments the minor segment and sets the micro segment to - zero, then appends :code:`.devN`. - -``setuptools_scm.local_scheme`` - Configures how the local part of a version is rendered given a - ``setuptools_scm.version.ScmVersion`` instance and should return a string - representing the local version. - Dates and times are in Coordinated Universal Time (UTC), because as part - of the version, they should be location independent. - - Available implementations: - - :node-and-date: adds the node on dev versions and the date on dirty - workdir (default) - :node-and-timestamp: like ``node-and-date`` but with a timestamp of - the form ``{:%Y%m%d%H%M%S}`` instead - :dirty-tag: adds ``+dirty`` if the current workdir has changes - :no-local-version: omits local version, useful e.g. because pypi does - not support it - - -Importing in ``setup.py`` -~~~~~~~~~~~~~~~~~~~~~~~~~ - -To support usage in ``setup.py`` passing a callable into ``use_scm_version`` -is supported. - -Within that callable, ``setuptools_scm`` is available for import. -The callable must return the configuration. - - -.. code:: python - - # content of setup.py - import setuptools - - def myversion(): - from setuptools_scm.version import get_local_dirty_tag - def clean_scheme(version): - return get_local_dirty_tag(version) if version.dirty else '+clean' - - return {'local_scheme': clean_scheme} - - setup( - ..., - use_scm_version=myversion, - ... - ) - - -Note on testing non-installed versions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -While the general advice is to test against a installed version, -some environments require a test prior to install, - -.. code:: - - $ python setup.py egg_info - $ PYTHONPATH=$PWD:$PWD/src pytest - - -Interaction with Enterprise Distributions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Some enterprise distributions like RHEL7 and others -ship rather old setuptools versions due to various release management details. - -On such distributions one might observe errors like: - -:code:``setuptools_scm.version.SetuptoolsOutdatedWarning: your setuptools is too old (<12)`` - -In those case its typically possible to build by using a sdist against ``setuptools_scm<2.0``. -As those old setuptools versions lack sensible types for versions, -modern setuptools_scm is unable to support them sensibly. - -In case the project you need to build can not be patched to either use old setuptools_scm, -its still possible to install a more recent version of setuptools in order to handle the build -and/or install the package by using wheels or eggs. - - - - -Code of Conduct ---------------- - -Everyone interacting in the ``setuptools_scm`` project's codebases, issue -trackers, chat rooms, and mailing lists is expected to follow the -`PyPA Code of Conduct`_. - -.. _PyPA Code of Conduct: https://www.pypa.io/en/latest/code-of-conduct/ - -Security Contact -================ - -To report a security vulnerability, please use the -`Tidelift security contact `_. -Tidelift will coordinate the fix and disclosure. diff --git a/_own_version_helper.py b/_own_version_helper.py new file mode 100644 index 0000000..9ef23f5 --- /dev/null +++ b/_own_version_helper.py @@ -0,0 +1,66 @@ +""" +this module is a hack only in place to allow for setuptools +to use the attribute for the versions + +it works only if the backend-path of the build-system section +from pyproject.toml is respected +""" +from __future__ import annotations + +import logging +from typing import Callable + +from setuptools import build_meta as build_meta # noqa + +from setuptools_scm import _types as _t +from setuptools_scm import Configuration +from setuptools_scm import get_version +from setuptools_scm import git +from setuptools_scm import hg +from setuptools_scm.fallbacks import parse_pkginfo +from setuptools_scm.version import get_local_node_and_date +from setuptools_scm.version import guess_next_dev_version +from setuptools_scm.version import ScmVersion + +log = logging.getLogger("setuptools_scm") +# todo: take fake entrypoints from pyproject.toml +try_parse: list[Callable[[_t.PathT, Configuration], ScmVersion | None]] = [ + parse_pkginfo, + git.parse, + hg.parse, + git.parse_archival, + hg.parse_archival, +] + + +def parse(root: str, config: Configuration) -> ScmVersion | None: + for maybe_parse in try_parse: + try: + parsed = maybe_parse(root, config) + except OSError as e: + log.warning("parse with %s failed with: %s", maybe_parse, e) + else: + if parsed is not None: + return parsed + else: + return None + + +def scm_version() -> str: + return get_version( + relative_to=__file__, + parse=parse, + version_scheme=guess_next_dev_version, + local_scheme=get_local_node_and_date, + ) + + +version: str + + +def __getattr__(name: str) -> str: + if name == "version": + global version + version = scm_version() + return version + raise AttributeError(name) diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..0acdf6f --- /dev/null +++ b/docs/config.md @@ -0,0 +1,156 @@ +# Configuration + + +## configuration parameters + +Configuration parameters can be configured in `pyproject.toml` or `setup.py`. +Callables or other Python objects have to be passed in `setup.py` (via the `use_scm_version` keyword argument). + + +`root : Path | PathLike[str]` +: Relative path to the SCM root, defaults to `.` and is relative to the file path passed in `relative_to` + +`version_scheme : str | Callable[[ScmVersion], str]` +: Configures how the local version number is constructed; either an entrypoint name or a callable. + +`local_scheme : str | Callable[[ScmVersion], str]` +: Configures how the local component of the version is constructed + either an entrypoint name or a callable. + + +`version_file: Path | PathLike[str] | None = None` +: A path to a file that gets replaced with a file containing the current + version. It is ideal for creating a ``_version.py`` file within the + package, typically used to avoid using `pkg_resources.get_distribution` + (which adds some overhead). + + !!! warning "" + + Only files with `.py` and `.txt` extensions have builtin templates, + for other file types it is necessary to provide `write_to_template`. + +`version_file_template_template: str | None = None` +: A new-style format string that is given the current version as + the `version` keyword argument for formatting. + +`write_to: Pathlike[str] | Path | None = None` +: (deprecated) legacy option to create a version file relative to the scm root + it's broken for usage from a sdist and fixing it would be a fatal breaking change, + use `version_file` instead. + +`relative_to: Path|Pathlike[str] = "pyproject.toml"` +: A file/directory from which the root can be resolved. + Typically called by a script or module that is not in the root of the + repository to point `setuptools_scm` at the root of the repository by + supplying `__file__`. + +`tag_regex: str|Pattern[str]` +: A Python regex string to extract the version part from any SCM tag. + The regex needs to contain either a single match group, or a group + named `version`, that captures the actual version information. + + Defaults to the value of [setuptools_scm._config.DEFAULT_TAG_REGEX][] + +`parentdir_prefix_version: str|None = None` +: If the normal methods for detecting the version (SCM version, + sdist metadata) fail, and the parent directory name starts with + `parentdir_prefix_version`, then this prefix is stripped and the rest of + the parent directory name is matched with `tag_regex` to get a version + string. If this parameter is unset (the default), then this fallback is + not used. + + This was intended to cover GitHub's "release tarballs", + which extract into directories named `projectname-tag/` + (in which case `parentdir_prefix_version` can be set e.g. to `projectname-`). + +`fallback_version: str | None = None` + : A version string that will be used if no other method for detecting the + version worked (e.g., when using a tarball with no metadata). If this is + unset (the default), `setuptools_scm` will error if it fails to detect the + version. + +`parse: Callable[[Path, Config], ScmVersion] | None = None` +: A function that will be used instead of the discovered SCM + for parsing the version. Use with caution, + this is a function for advanced use and you should be + familiar with the `setuptools_scm` internals to use it. + +`git_describe_command` +: This command will be used instead the default `git describe --long` command. + + Defaults to the value set by [setuptools_scm.git.DEFAULT_DESCRIBE][] + +`normalize` +: A boolean flag indicating if the version string should be normalized. + Defaults to `True`. Setting this to `False` is equivalent to setting + `version_cls` to [setuptools_scm.NonNormalizedVersion][] + +`version_cls: type|str = packaging.version.Version` +: An optional class used to parse, verify and possibly normalize the version + string. Its constructor should receive a single string argument, and its + `str` should return the normalized version string to use. + This option can also receive a class qualified name as a string. + + The [setuptools_scm.NonNormalizedVersion][] convenience class is + provided to disable the normalization step done by + `packaging.version.Version`. If this is used while `setuptools_scm` + is integrated in a setuptools packaging process, the non-normalized + version number will appear in all files (see `version_file` note). + + !!! note "normalization still applies to artifact filenames" + Setuptools will still normalize it to create the final distribution, + so as to stay compliant with the python packaging standards. + + +## environment variables + +`SETUPTOOLS_SCM_PRETEND_VERSION` +: used as the primary source for the version number + in which case it will be an unparsed string + + !!! warning "it is strongly recommended to use use distribution name specific pretend versions" + + +`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${NORMALIZED_DIST_NAME}` +: used as the primary source for the version number + in which case it will be an unparsed string + + the dist name normalization follows adapted PEP 503 semantics, with one or + more of ".-_" being replaced by a single "_", and the name being upper-cased + + it takes precedence over ``SETUPTOOLS_SCM_PRETEND_VERSION`` + +`SETUPTOOLS_SCM_DEBUG` +: enable the debug logging + +`SOURCE_DATE_EPOCH` +: used as the timestamp from which the + ``node-and-date`` and ``node-and-timestamp`` local parts are + derived, otherwise the current time is used + (https://reproducible-builds.org/docs/source-date-epoch/) + +`SETUPTOOLS_SCM_IGNORE_VCS_ROOTS` +: a ``os.pathsep`` separated list + of directory names to ignore for root finding + + + + + +## api reference + +### constants + +::: setuptools_scm._config.DEFAULT_TAG_REGEX + options: + heading_level: 4 + +::: setuptools_scm.git.DEFAULT_DESCRIBE + options: + heading_level: 4 + + +### the configuration class +::: setuptools_scm.Configuration + options: + heading_level: 4 diff --git a/docs/customizing.md b/docs/customizing.md new file mode 100644 index 0000000..9236531 --- /dev/null +++ b/docs/customizing.md @@ -0,0 +1,77 @@ +# Customizing + +## providing project local version schemes + +As PEP 621 provides no way to specify local code as a build backend plugin, +setuptools_scm has to piggyback on setuptools for passing functions over. + +To facilitate that one needs to write a `setup.py` file and +pass partial setuptools_scm configuration in via the use_scm_version keyword. + +It's strongly recommended to experiment with using stock version schemes or creating plugins as package. +(This recommendation will change if there ever is something like build-time entrypoints). + + +``` { .python title="setup.py" file="docs/examples/version_scheme_code/setup.py" } +# we presume installed build dependencies +from __future__ import annotations + +from setuptools import setup + +from setuptools_scm import ScmVersion + + +def myversion_func(version: ScmVersion) -> str: + from setuptools_scm.version import guess_next_version + + return version.format_next_version(guess_next_version, "{guessed}b{distance}") + + +setup(use_scm_version={"version_scheme": myversion_func}) +``` + + +``` { .python title="pyproject.toml" file="docs/examples/version_scheme_code/pyproject.toml" } +[build-system] +requires = [ + "setuptools>=64", + "setuptools_scm>=8", + "wheel", +] + +[project] +name = "scm-example" +dynamic = [ + "version", +] + +[tool.setuptools_scm] +``` + +- [ ] add a build block that adds example output +- [ ] correct config after [entangled mkdocs bug] is fixed + +[entangled mkdocs bug]: https://github.com/entangled/mkdocs-plugin/issues/1 + + + + +## Importing in setup.py + +With the pep 517/518 build backend, setuptools_scm is importable from `setup.py` + +``` { .python title="setup.py" } +import setuptools +from setuptools_scm.version import get_local_dirty_tag + +def clean_scheme(version): + return get_local_dirty_tag(version) if version.dirty else '+clean' + +setup(use_scm_version={'local_scheme': clean_scheme}) +``` + + + +## alternative version classes + +::: setuptools_scm.NonNormalizedVersion diff --git a/docs/examples/version_scheme_code/pyproject.toml b/docs/examples/version_scheme_code/pyproject.toml new file mode 100644 index 0000000..05da2f6 --- /dev/null +++ b/docs/examples/version_scheme_code/pyproject.toml @@ -0,0 +1,16 @@ +# ~/~ begin <>[init] +[build-system] +requires = [ + "setuptools>=64", + "setuptools_scm>=8", + "wheel", +] + +[project] +name = "scm-example" +dynamic = [ + "version", +] + +[tool.setuptools_scm] +# ~/~ end \ No newline at end of file diff --git a/docs/examples/version_scheme_code/setup.py b/docs/examples/version_scheme_code/setup.py new file mode 100644 index 0000000..69f903f --- /dev/null +++ b/docs/examples/version_scheme_code/setup.py @@ -0,0 +1,17 @@ +# ~/~ begin <>[init] +# we presume installed build dependencies +from __future__ import annotations + +from setuptools import setup + +from setuptools_scm import ScmVersion + + +def myversion_func(version: ScmVersion) -> str: + from setuptools_scm.version import guess_next_version + + return version.format_next_version(guess_next_version, "{guessed}b{distance}") + + +setup(use_scm_version={"version_scheme": myversion_func}) +# ~/~ end diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..957c762 --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,107 @@ +# Extending setuptools_scm + +`setuptools_scm` uses [entry-point][entry-point] based hooks to extend its default capabilities. + +[entry-point]: https://packaging.python.org/en/latest/specifications/entry-points/ + +## Adding a new SCM + +`setuptools_scm` provides two entrypoints for adding new SCMs: + +`setuptools_scm.parse_scm` +: A function used to parse the metadata of the current workdir + using the name of the control directory/file of your SCM as the + entrypoint's name. E.g. for the built-in entrypoint for Git the + entrypoint is named `.git` and references `setuptools_scm.git:parse` + + The return value MUST be a [`setuptools_scm.version.ScmVersion`][] instance + created by the function [`setuptools_scm.version.meta`][]. + +`setuptools_scm.files_command` +: Either a string containing a shell command that prints all SCM managed + files in its current working directory or a callable, that given a + pathname will return that list. + + Also uses then name of your SCM control directory as name of the entrypoint. + + +### api reference for scm version objects + +::: setuptools_scm.version.ScmVersion + options: + show_root_heading: yes + heading_level: 4 + +::: setuptools_scm.version.meta + options: + show_root_heading: yes + heading_level: 4 + +## Version number construction + + + + + +### `setuptools_scm.version_scheme` +Configures how the version number is constructed given a +[ScmVersion][setuptools_scm.version.ScmVersion] instance and should return a string +representing the version. + +### Available implementations + +`guess-next-dev (default)` +: Automatically guesses the next development version (default). + Guesses the upcoming release by incrementing the pre-release segment if present, + otherwise by incrementing the micro segment. Then appends :code:`.devN`. + In case the tag ends with `.dev0` the version is not bumped + and custom `.devN` versions will trigger a error. + +`post-release (deprecated)` +: Generates post release versions (adds `.postN`) + after review of the version number pep this is considered a bad idea + as post releases are intended to be chosen not autogenerated. + + !!! warning "the recommended replacement is `no-guess-dev`" + +`python-simplified-semver` +: Basic semantic versioning. + + Guesses the upcoming release by incrementing the minor segment + and setting the micro segment to zero if the current branch contains the string `feature`, + otherwise by incrementing the micro version. Then appending `.devN`. + + This scheme is not compatible with pre-releases. + +`release-branch-semver` +: Semantic versioning for projects with release branches. + The same as `guess-next-dev` (incrementing the pre-release or micro segment) + however when on a release branch: a branch whose name (ignoring namespace) parses as a version + that matches the most recent tag up to the minor segment. Otherwise if on a + non-release branch, increments the minor segment and sets the micro segment to + zero, then appends `.devN` + +`no-guess-dev` +: Does no next version guessing, just adds `.post1.devN` + + +### `setuptools_scm.local_scheme` +Configures how the local part of a version is rendered given a +[ScmVersion][setuptools_scm.version.ScmVersion] instance and should return a string +representing the local version. +Dates and times are in Coordinated Universal Time (UTC), because as part +of the version, they should be location independent. + +#### Available implementations + +`node-and-date (default)` +: adds the node on dev versions and the date on dirty workdir + +`node-and-timestamp` +: like `node-and-date` but with a timestamp of the form `%Y%m%d%H%M%S` instead + +`dirty-tag` +: adds `+dirty` if the current workdir has changes + +`no-local-version` +: omits local version, useful e.g. because pypi does not support it diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d1c3995 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,44 @@ +# About + + +`setuptools_scm` extracts Python package versions from `git` or `hg` metadata +instead of declaring them as the version argument or in a SCM managed file. + +Additionally `setuptools_scm` provides setuptools with a list of +files that are managed by the SCM (i.e. it automatically adds all +the SCM-managed files to the sdist). Unwanted files must be excluded +via `MANIFEST.in`. + +## basic usage + +### with setuptools + +Note: `setuptools_scm>=8` intentionally doesn't depend on setuptools to ease non-setuptools usage. +Please ensure a recent version of setuptools (>=64) is installed. + + +```toml +# pyproject.toml +[build-system] +requires = [ + "setuptools>=64", + "setuptools_scm>=8", + "wheel", +] +[project] +name = "example" +# Important: Remove any existing version declaration +# version = "0.0.1" +dynamic = ["version"] +# more missing + +[tool.setuptools_scm] + +``` + + +### with hatch + +[Hatch-vcs](https://github.com/ofek/hatch-vcs) integrates with setuptools_scm +but provides its own configuration options, +please see its [documentation](https://github.com/ofek/hatch-vcs#readme) diff --git a/docs/overrides.md b/docs/overrides.md new file mode 100644 index 0000000..5114a84 --- /dev/null +++ b/docs/overrides.md @@ -0,0 +1,16 @@ +# Overrides + +## pretend versions + +setuptools_scm provides a mechanism to override the version number build time. + +the environment variable `SETUPTOOLS_SCM_PRETEND_VERSION` is used +as the override source for the version number unparsed string. + +to be specific about the package this applies for, one can use `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${NORMALIZED_DIST_NAME}` +where the dist name normalization follows adapted PEP 503 semantics. + +## config overrides + +setuptools_scm parses the environment variable `SETUPTOOLS_SCM_OVERRIDES_FOR_${NORMALIZED_DIST_NAME}` +as a toml inline map to override the configuration data from `pyproject.toml`. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..8392796 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,275 @@ +# Usage + +## at build time + +The preferred way to configure `setuptools_scm` is to author +settings in the `tool.setuptools_scm` section of `pyproject.toml`. + +It's necessary to use a setuptools version released after 2022. + +```toml +# pyproject.toml +[build-system] +requires = ["setuptools>=62", "setuptools_scm[toml]>=8.0"] + +[project] +# version = "0.0.1" # Remove any existing version parameter. +dynamic = ["version"] + +[tool.setuptools_scm] +# can be empty if no extra settings are needed, presence enables setuptools_scm +``` + +That will be sufficient to require `setuptools_scm` for projects +that support PEP 518 ([pip](https://pypi.org/project/pip) and +[pep517](https://pypi.org/project/pep517/)). +Tools that still invoke `setup.py` must ensure build requirements are installed + +### version files + +```toml +# pyproject.toml +... +[tool.setuptools_scm] +version_file = "pkg/_version.py" +``` +Where ``pkg`` is the name of your package. + + +.. code-block:: shell + + $ python -m setuptools_scm + + # To explore other options, try: + $ python -m setuptools_scm --help + + +## as cli tool + +If you need to confirm which version string is being generated +or debug the configuration, you can install +[setuptools-scm](https://github.com/pypa/setuptools_scm) +directly in your working environment and run: + +```commandline +$ python -m setuptools_scm # example from running local after changes +7.1.1.dev149+g5197d0f.d20230727 +``` + + and to list all tracked by the scm: + +```commandline +$ python -m setuptools_scm ls # output trimmed for brevity +./LICENSE +... +./src/setuptools_scm/__init__.py +./src/... +... +``` + +!!! note "committed files only" + + currently only committed files are listed, this might change in the future + +!!! warning "sdists/archives don't provide file lists" + + currently there is no builtin mechanism + to safely transfer the file lists to sdists or obtaining them from archives + coordination for setuptools and hatch is ongoing + +## at runtime (strongly discouraged) + +the most simple **looking** way to use `setuptools_scm` at runtime is: + +```python +from setuptools_scm import get_version +version = get_version() +``` + + +In order to use `setuptools_scm` from code that is one directory deeper +than the project's root, you can use: + +```python +from setuptools_scm import get_version +version = get_version(root='..', relative_to=__file__) +``` + + +## Python package metadata + + + + +### version at runtime + +If you have opted not to hardcode the version number inside the package, +you can retrieve it at runtime from PEP-0566_ metadata using +``importlib.metadata`` from the standard library (added in Python 3.8) +or the `importlib_metadata`_ backport: + +```python +# contents of package_name/__init__.py +from importlib.metadata import version, PackageNotFoundError + +try: + __version__ = version("package-name") +except PackageNotFoundError: + # package is not installed + pass +``` + +.. _PEP-0566: https://www.python.org/dev/peps/pep-0566/ +.. _importlib_metadata: https://pypi.org/project/importlib-metadata/ + + +### Usage from Sphinx + + +``` {.python file=docs/.entangled/sphinx_conf.py} +from importlib.metadata import version as get_version +release: str = get_version('setuptools_scm') +# for example take major/minor +version: str = ".".join(release.split('.')[:2]) +``` + +The underlying reason is that services like *Read the Docs* sometimes change +the working directory for good reasons and using the installed metadata +prevents using needless volatile data there. + + +## with Docker/Podman + + +By default, Docker will not copy the `.git` folder into your container. +Therefore, builds with version inference might fail. +Consequently, you can use the following snippet to infer the version from +the host OS without copying the entire `.git` folder to your `Dockerfile`. + +```dockerfile +RUN --mount=source=.git,target=.git,type=bind \ + pip install --no-cache-dir -e . +``` +However, this build step introduces a dependency to the state of your local +`.git` folder the build cache and triggers the long-running pip install process on every build. +To optimize build caching, one can use an environment variable to pretend a pseudo +version that is used to cache the results of the pip install process: + + +```dockerfile +FROM python +COPY pyproject.toml +ARG PSEUDO_VERSION=1 # strongly recommended to update based on git describe +RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MY_PACKAGE=${PSEUDO_VERSION} pip install -e .[test] +RUN --mount=source=.git,target=.git,type=bind pip install -e . +``` + +Note that running this Dockerfile requires docker with BuildKit enabled +[docs](https://github.com/moby/buildkit/blob/v0.8.3/frontend/dockerfile/docs/syntax.md). + +To avoid BuildKit and mounting of the .git folder altogether, one can also pass the desired +version as a build argument. +Note that `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${NORMALIZED_DIST_NAME}` +is preferred over `SETUPTOOLS_SCM_PRETEND_VERSION`. + + + +## Default versioning scheme + +In the standard configuration `setuptools_scm` takes a look at three things: + +1. latest tag (with a version number) +2. the distance to this tag (e.g. number of revisions since latest tag) +3. workdir state (e.g. uncommitted changes since latest tag) + +and uses roughly the following logic to render the version: + + +| distance | state | format | +|----------|-----------|----------------------------------------------------------------------| +| no | unchanged | `{tag}` | +| yes | unchanged | `{next_version}.dev{distance}+{scm letter}{revision hash}` | +| no | changed | `{tag}+dYYYYMMDD` | +| yes | changed | `{next_version}.dev{distance}+{scm letter}{revision hash}.dYYYYMMDD` | + +where `{next_version}` is the next version number after the latest tag + +The next version is calculated by adding `1` to the last numeric component of +the tag. + +For Git projects, the version relies on [git describe](https://git-scm.com/docs/git-describe), +so you will see an additional `g` prepended to the `{revision hash}`. + + +!!! note + + According to [PEP 440](https://peps.python.org/pep-0440/#local-version-identifiers>), + if a version includes a local component, the package cannot be published to public + package indexes like PyPI or TestPyPI. The disallowed version segments may + be seen in auto-publishing workflows or when a configuration mistake is made. + + However, some package indexes such as devpi or other alternatives allow local + versions. Local version identifiers must comply with [PEP 440]. + +## Semantic Versioning (SemVer) + +Due to the default behavior it's necessary to always include a +patch version (the `3` in `1.2.3`), or else the automatic guessing +will increment the wrong part of the SemVer (e.g. tag `2.0` results in +`2.1.devX` instead of `2.0.1.devX`). So please make sure to tag +accordingly. + + +## Builtin mechanisms for obtaining version numbers + +1. the SCM itself (Git/Mercurial) +2. `.hg_archival` files (Mercurial archives) +3. `.git_archival.txt` files (Git archives, see subsection below) +4. `PKG-INFO` + + +### Git archives + +Git archives are supported, but a few changes to your repository are required. + +Ensure the content of the following files: + +```{ .text file=".git_archival.txt"} + +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +ref-names: $Format:%D$ +``` + +``` {.text file=".gitattributes"} +.git_archival.txt export-subst +``` + +Finally, don't forget to commit the two files: +```commandline +$ git add .git_archival.txt .gitattributes && git commit -m "add export config" +``` + + +Note that if you are creating a `_version.py` file, note that it should not +be kept in version control. It's strongly recommended to be put into gitignore. + + + +### File finders hook makes most of MANIFEST.in unnecessary + +`setuptools_scm` implements a [file_finders] entry point +which returns all files tracked by your SCM. +This eliminates the need for a manually constructed `MANIFEST.in` in most cases where this +would be required when not using `setuptools_scm`, namely: + +* To ensure all relevant files are packaged when running the `sdist` command. + * When using [include_package_data] to include package data as part of the `build` or `bdist_wheel`. + +`MANIFEST.in` may still be used: anything defined there overrides the hook. +This is mostly useful to exclude files tracked in your SCM from packages, +although in principle it can be used to explicitly include non-tracked files too. + +[file_finders]: https://setuptools.pypa.io/en/latest/userguide/extension.html#adding-support-for-revision-control-systems +[include_package_data]: https://setuptools.readthedocs.io/en/latest/setuptools.html#including-data-files diff --git a/hatch.toml b/hatch.toml new file mode 100644 index 0000000..aad1b87 --- /dev/null +++ b/hatch.toml @@ -0,0 +1,23 @@ +[envs.test] +extras = ["test", "dev"] + +[envs.test.scripts] +all = "pytest {args}" + +[[env.test.matrix]] +python = ["3.8", "3.9", "3.10", "3.11"] + + +[envs.docs] +python = "3.11" +extras = ["docs"] +dependencies = ["scriv"] + +[envs.docs.scripts] +build = "mkdocs build --clean --strict" +serve = "mkdocs serve --dev-addr localhost:8000" +init = "mkdocs {args}" +sync = ["entangled sync"] + +changelog-create = "scriv create {args}" +changelog-collect = "scriv collect {args}" \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..79ad48a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,37 @@ +site_name: setuptools scm + +nav: + - index.md + - usage.md + - customizing.md + - config.md + - extending.md + - overrides.md +theme: + name: material + +watch: +- src/setuptools_scm +- docs +markdown_extensions: + - def_list + - admonition + - pymdownx.tasklist: + custom_checkbox: true + + - pymdownx.superfences + +plugins: +- entangled +- search +- mkdocstrings: + default_handler: python + handlers: + python: + paths: [ src ] + + options: + separate_signature: true + show_signature_annotations: true + allow_inspection: true + show_root_heading: true diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..ef383f7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +python_version = 3.8 +warn_return_any = True +warn_unused_configs = True +mypy_path = $MYPY_CONFIG_FILE_DIR/src +strict = true diff --git a/packaging/python3-setuptools_scm.manifest b/packaging/python3-setuptools_scm.manifest new file mode 100644 index 0000000..017d22d --- /dev/null +++ b/packaging/python3-setuptools_scm.manifest @@ -0,0 +1,5 @@ + + + + + diff --git a/packaging/python3-setuptools_scm.spec b/packaging/python3-setuptools_scm.spec new file mode 100644 index 0000000..20c9776 --- /dev/null +++ b/packaging/python3-setuptools_scm.spec @@ -0,0 +1,41 @@ +Name: python3-setuptools_scm +Version: 8.0.4 +Release: 1 +Summary: setuptools_scm handles managing your Python package versions +License: MIT +Group: Development/Languages/Python +Source: setuptools_scm-%{version}.tar.gz +Source1001: %{name}.manifest +Source1002: setuptools_scm-8.0.4-py3-none-any.whl + +BuildRequires: pkgconfig(python3) +BuildRequires: python3-pip +BuildRequires: python3-packaging +BuildRequires: python3-typing_extensions + +Requires: python3-packaging +Requires: python3-typing_extensions + +BuildArch: noarch + +%description +setuptools_scm handles managing your Python package versions in SCM metadata instead of declaring them as the version argument or in a SCM managed file. + +%prep +%setup -q -n setuptools_scm-%{version} + +%build +cp %{SOURCE1001} . + +%install +%{_bindir}/python3 -m pip install --root %{buildroot} %{SOURCE1002} +rm -f %{buildroot}/%{python3_sitelib}/setuptools_scm/.git_archival.txt + +%files +%manifest %{name}.manifest +%license LICENSE +%{python3_sitelib}/setuptools_scm/*.py +%{python3_sitelib}/setuptools_scm/__pycache__/* +%{python3_sitelib}/setuptools_scm/_file_finders/* +%{python3_sitelib}/setuptools_scm/_integration/* +%{python3_sitelib}/setuptools_scm-%{version}.dist-info/* diff --git a/packaging/setuptools_scm-8.0.4-py3-none-any.whl b/packaging/setuptools_scm-8.0.4-py3-none-any.whl new file mode 100644 index 0000000..314c5d2 Binary files /dev/null and b/packaging/setuptools_scm-8.0.4-py3-none-any.whl differ diff --git a/pyproject.toml b/pyproject.toml index f90d4d1..833400b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,135 @@ + + [build-system] -requires = ["setuptools>=34.4", "wheel"] -build-backend = "setuptools.build_meta" +build-backend = "_own_version_helper:build_meta" +requires = [ + "setuptools>=61", + 'tomli; python_version <= "3.12"', +] +backend-path = [ + ".", + "src", +] + +[project] +name = "setuptools-scm" +description = "the blessed package to manage your versions by scm tags" +readme = "README.md" +license.file = "LICENSE" +authors = [ + {name="Ronny Pfannschmidt", email="opensource@ronnypfannschmidt.de"} +] +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Version Control", + "Topic :: System :: Software Distribution", + "Topic :: Utilities", +] +dynamic = [ + "version", +] +dependencies = [ + "packaging>=20", + "setuptools", + 'tomli>=1; python_version <= "3.12"', + "typing-extensions", +] +[project.optional-dependencies] +docs = [ + "entangled_cli[rich]", + "mkdocs", + "mkdocs-entangled-plugin", + "mkdocs-material", + "mkdocstrings[python]", + "pygments", +] +rich = [ + "rich", +] +test = [ + "build", + "pytest", + "rich", + "wheel", +] +toml = [ +] +[project.urls] +documentation = "https://setuptools-scm.readthedocs.io/" +repository = "https://github.com/pypa/setuptools_scm/" +[project.entry-points."distutils.setup_keywords"] +use_scm_version = "setuptools_scm._integration.setuptools:version_keyword" +[project.entry-points."setuptools.file_finders"] +setuptools_scm = "setuptools_scm._file_finders:find_files" +[project.entry-points."setuptools.finalize_distribution_options"] +setuptools_scm = "setuptools_scm._integration.setuptools:infer_version" +[project.entry-points."setuptools_scm.files_command"] +".git" = "setuptools_scm._file_finders.git:git_find_files" +".hg" = "setuptools_scm._file_finders.hg:hg_find_files" +[project.entry-points."setuptools_scm.files_command_fallback"] +".git_archival.txt" = "setuptools_scm._file_finders.git:git_archive_find_files" +".hg_archival.txt" = "setuptools_scm._file_finders.hg:hg_archive_find_files" +[project.entry-points."setuptools_scm.local_scheme"] +dirty-tag = "setuptools_scm.version:get_local_dirty_tag" +no-local-version = "setuptools_scm.version:get_no_local_node" +node-and-date = "setuptools_scm.version:get_local_node_and_date" +node-and-timestamp = "setuptools_scm.version:get_local_node_and_timestamp" +[project.entry-points."setuptools_scm.parse_scm"] +".git" = "setuptools_scm.git:parse" +".hg" = "setuptools_scm.hg:parse" +[project.entry-points."setuptools_scm.parse_scm_fallback"] +".git_archival.txt" = "setuptools_scm.git:parse_archival" +".hg_archival.txt" = "setuptools_scm.hg:parse_archival" +PKG-INFO = "setuptools_scm.fallbacks:parse_pkginfo" +"pyproject.toml" = "setuptools_scm.fallbacks:fallback_version" +"setup.py" = "setuptools_scm.fallbacks:fallback_version" +[project.entry-points."setuptools_scm.version_scheme"] +"calver-by-date" = "setuptools_scm.version:calver_by_date" +"guess-next-dev" = "setuptools_scm.version:guess_next_dev_version" +"no-guess-dev" = "setuptools_scm.version:no_guess_dev_version" +"post-release" = "setuptools_scm.version:postrelease_version" +"python-simplified-semver" = "setuptools_scm.version:simplified_semver_version" +"release-branch-semver" = "setuptools_scm.version:release_branch_semver_version" + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.setuptools.dynamic] +version = { attr = "_own_version_helper.version"} + +[tool.setuptools_scm] + +[tool.ruff] +select = ["E", "F", "B", "U", "YTT", "C", "DTZ", "PYI", "PT"] +ignore = ["B028"] + +[tool.pytest.ini_options] +testpaths = ["testing"] +filterwarnings = [ + "error", + "ignore:.*tool\\.setuptools_scm.*", + "ignore:.*git archive did not support describe output.*:UserWarning", +] +log_level = "debug" +log_cli_level = "info" +# disable unraisable until investigated +addopts = ["-p", "no:unraisableexception"] +markers = [ + "issue(id): reference to github issue", + "skip_commit: allows to skip committing in the helpers", +] + +[tool.scriv] +format = "md" diff --git a/setup.cfg b/setup.cfg index 110f700..8bfd5a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,75 +1,3 @@ -[metadata] -license_file = LICENSE -license = MIT -name = setuptools_scm -url = https://github.com/pypa/setuptools_scm/ -author = Ronny Pfannschmidt -author_email = opensource@ronnypfannschmidt.de -description = the blessed package to manage your versions by scm tags -long_description = file:README.rst -classifiers = - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Programming Language :: Python - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Topic :: Software Development :: Libraries - Topic :: Software Development :: Version Control - Topic :: System :: Software Distribution - Topic :: Utilities - -[options] -zip_safe = true -python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* -install_requires = - setuptools -packages = find: -package_dir = - =src - -[options.packages.find] -where = src - -[options.extras_require] -toml = toml - -[options.entry_points] -distutils.setup_keywords = - use_scm_version = setuptools_scm.integration:version_keyword -setuptools.file_finders = - setuptools_scm = setuptools_scm.integration:find_files -setuptools.finalize_distribution_options = - setuptools_scm = setuptools_scm.integration:infer_version -setuptools_scm.parse_scm = - .hg = setuptools_scm.hg:parse - .git = setuptools_scm.git:parse -setuptools_scm.parse_scm_fallback = - .hg_archival.txt = setuptools_scm.hg:parse_archival - PKG-INFO = setuptools_scm.hacks:parse_pkginfo - pip-egg-info = setuptools_scm.hacks:parse_pip_egg_info - setup.py = setuptools_scm.hacks:fallback_version -setuptools_scm.files_command = - .hg = setuptools_scm.file_finder_hg:hg_find_files - .git = setuptools_scm.file_finder_git:git_find_files -setuptools_scm.version_scheme = - guess-next-dev = setuptools_scm.version:guess_next_dev_version - post-release = setuptools_scm.version:postrelease_version - python-simplified-semver = setuptools_scm.version:simplified_semver_version - release-branch-semver = setuptools_scm.version:release_branch_semver_version -setuptools_scm.local_scheme = - node-and-date = setuptools_scm.version:get_local_node_and_date - node-and-timestamp = setuptools_scm.version:get_local_node_and_timestamp - dirty-tag = setuptools_scm.version:get_local_dirty_tag - no-local-version = setuptools_scm.version:get_no_local_node - -[bdist_wheel] -universal = 1 - [egg_info] tag_build = tag_date = 0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0b3e0c7..0000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -"""\ -important note: - -the setup of setuptools_scm is self-using, -the first execution of `python setup.py egg_info` -will generate partial data -its critical to run `python setup.py egg_info` -once before running sdist or easy_install on a fresh checkouts - -pip usage is recommended -""" -from __future__ import print_function -import os -import sys -import setuptools - - -def scm_config(): - here = os.path.dirname(os.path.abspath(__file__)) - src = os.path.join(here, "src") - egg_info = os.path.join(src, "setuptools_scm.egg-info") - has_entrypoints = os.path.isdir(egg_info) - import pkg_resources - - sys.path.insert(0, src) - pkg_resources.working_set.add_entry(src) - # FIXME: remove debug - print(src) - print(pkg_resources.working_set) - from setuptools_scm.hacks import parse_pkginfo - from setuptools_scm.git import parse as parse_git - from setuptools_scm.version import guess_next_dev_version, get_local_node_and_date - - def parse(root): - try: - return parse_pkginfo(root) - except IOError: - return parse_git(root) - - config = dict( - version_scheme=guess_next_dev_version, local_scheme=get_local_node_and_date - ) - - if has_entrypoints: - return dict(use_scm_version=config) - else: - from setuptools_scm import get_version - - return dict(version=get_version(root=here, parse=parse, **config)) - - -if __name__ == "__main__": - setuptools.setup(**scm_config()) diff --git a/src/setuptools_scm.egg-info/PKG-INFO b/src/setuptools_scm.egg-info/PKG-INFO deleted file mode 100644 index ea5bbd2..0000000 --- a/src/setuptools_scm.egg-info/PKG-INFO +++ /dev/null @@ -1,615 +0,0 @@ -Metadata-Version: 2.1 -Name: setuptools-scm -Version: 4.1.2 -Summary: the blessed package to manage your versions by scm tags -Home-page: https://github.com/pypa/setuptools_scm/ -Author: Ronny Pfannschmidt -Author-email: opensource@ronnypfannschmidt.de -License: MIT -Description: setuptools_scm - =============== - - ``setuptools_scm`` handles managing your Python package versions - in SCM metadata instead of declaring them as the version argument - or in a SCM managed file. - - Additionally ``setuptools_scm`` provides setuptools with a list of files that are managed by the SCM - (i.e. it automatically adds all of the SCM-managed files to the sdist). - Unwanted files must be excluded by discarding them via ``MANIFEST.in``. - - .. image:: https://travis-ci.org/pypa/setuptools_scm.svg?branch=master - :target: https://travis-ci.org/pypa/setuptools_scm - - .. image:: https://tidelift.com/badges/package/pypi/setuptools-scm - :target: https://tidelift.com/subscription/pkg/pypi-setuptools-scm?utm_source=pypi-setuptools-scm&utm_medium=readme - - - ``pyproject.toml`` usage - ------------------------ - - The preferred way to configure ``setuptools_scm`` is to author - settings in a ``tool.setuptools_scm`` section of ``pyproject.toml``. - - This feature requires Setuptools 42 or later, released in Nov, 2019. - If your project needs to support build from sdist on older versions - of Setuptools, you will need to also implement the ``setup.py usage`` - for those legacy environments. - - First, ensure that ``setuptools_scm`` is present during the project's - built step by specifying it as one of the build requirements. - - .. code:: toml - - # pyproject.toml - [build-system] - requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] - - Note that the ``toml`` extra must be supplied. - - That will be sufficient to require ``setuptools_scm`` for projects - that support PEP 518 (`pip `_ and - `pep517 `_). Many tools, - especially those that invoke ``setup.py`` for any reason, may - continue to rely on ``setup_requires``. For maximum compatibility - with those uses, consider also including a ``setup_requires`` directive - (described below in ``setup.py usage`` and ``setup.cfg``). - - To enable version inference, add this section to your pyproject.toml: - - .. code:: toml - - # pyproject.toml - [tool.setuptools_scm] - - Including this section is comparable to supplying - ``use_scm_version=True`` in ``setup.py``. Additionally, - include arbitrary keyword arguments in that section - to be supplied to ``get_version()``. For example: - - .. code:: toml - - # pyproject.toml - - [tool.setuptools_scm] - write_to = "pkg/version.py" - - - ``setup.py`` usage - ------------------ - - The following settings are considered legacy behavior and - superseded by the ``pyproject.toml`` usage, but for maximal - compatibility, projects may also supply the configuration in - this older form. - - To use ``setuptools_scm`` just modify your project's ``setup.py`` file - like this: - - * Add ``setuptools_scm`` to the ``setup_requires`` parameter. - * Add the ``use_scm_version`` parameter and set it to ``True``. - - For example: - - .. code:: python - - from setuptools import setup - setup( - ..., - use_scm_version=True, - setup_requires=['setuptools_scm'], - ..., - ) - - Arguments to ``get_version()`` (see below) may be passed as a dictionary to - ``use_scm_version``. For example: - - .. code:: python - - from setuptools import setup - setup( - ..., - use_scm_version = { - "root": "..", - "relative_to": __file__, - "local_scheme": "node-and-timestamp" - }, - setup_requires=['setuptools_scm'], - ..., - ) - - You can confirm the version number locally via ``setup.py``: - - .. code-block:: shell - - $ python setup.py --version - - .. note:: - - If you see unusual version numbers for packages but ``python setup.py - --version`` reports the expected version number, ensure ``[egg_info]`` is - not defined in ``setup.cfg``. - - - ``setup.cfg`` usage - ------------------- - - If using `setuptools 30.3.0 - `_ - or greater, you can store ``setup_requires`` configuration in ``setup.cfg``. - However, ``use_scm_version`` must still be placed in ``setup.py``. For example: - - .. code:: python - - # setup.py - from setuptools import setup - setup( - use_scm_version=True, - ) - - .. code:: ini - - # setup.cfg - [metadata] - ... - - [options] - setup_requires = - setuptools_scm - ... - - .. important:: - - Ensure neither the ``[metadata]`` ``version`` option nor the ``[egg_info]`` - section are defined, as these will interfere with ``setuptools_scm``. - - You may also need to define a ``pyproject.toml`` file (`PEP-0518 - `_) to ensure you have the required - version of ``setuptools``: - - .. code:: ini - - # pyproject.toml - [build-system] - requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] - - For more information, refer to the `setuptools issue #1002 - `_. - - - Programmatic usage - ------------------ - - In order to use ``setuptools_scm`` from code that is one directory deeper - than the project's root, you can use: - - .. code:: python - - from setuptools_scm import get_version - version = get_version(root='..', relative_to=__file__) - - See `setup.py Usage`_ above for how to use this within ``setup.py``. - - - Retrieving package version at runtime - ------------------------------------- - - If you have opted not to hardcode the version number inside the package, - you can retrieve it at runtime from PEP-0566_ metadata using - ``importlib.metadata`` from the standard library - or the `importlib_metadata`_ backport: - - .. code:: python - - from importlib.metadata import version, PackageNotFoundError - - try: - __version__ = version(__name__) - except PackageNotFoundError: - # package is not installed - pass - - Alternatively, you can use ``pkg_resources`` which is included in - ``setuptools``: - - .. code:: python - - from pkg_resources import get_distribution, DistributionNotFound - - try: - __version__ = get_distribution(__name__).version - except DistributionNotFound: - # package is not installed - pass - - This does place a runtime dependency on ``setuptools``. - - .. _PEP-0566: https://www.python.org/dev/peps/pep-0566/ - .. _importlib_metadata: https://pypi.org/project/importlib-metadata/ - - - Usage from Sphinx - ----------------- - - It is discouraged to use ``setuptools_scm`` from Sphinx itself, - instead use ``pkg_resources`` after editable/real installation: - - .. code:: python - - # contents of docs/conf.py - from pkg_resources import get_distribution - release = get_distribution('myproject').version - # for example take major/minor - version = '.'.join(release.split('.')[:2]) - - The underlying reason is, that services like *Read the Docs* sometimes change - the working directory for good reasons and using the installed metadata - prevents using needless volatile data there. - - Notable Plugins - ---------------- - - `setuptools_scm_git_archive `_ - Provides partial support for obtaining versions from git archives that - belong to tagged versions. The only reason for not including it in - ``setuptools_scm`` itself is Git/GitHub not supporting sufficient metadata - for untagged/followup commits, which is preventing a consistent UX. - - - Default versioning scheme - -------------------------- - - In the standard configuration ``setuptools_scm`` takes a look at three things: - - 1. latest tag (with a version number) - 2. the distance to this tag (e.g. number of revisions since latest tag) - 3. workdir state (e.g. uncommitted changes since latest tag) - - and uses roughly the following logic to render the version: - - no distance and clean: - ``{tag}`` - distance and clean: - ``{next_version}.dev{distance}+{scm letter}{revision hash}`` - no distance and not clean: - ``{tag}+dYYYYMMDD`` - distance and not clean: - ``{next_version}.dev{distance}+{scm letter}{revision hash}.dYYYYMMDD`` - - The next version is calculated by adding ``1`` to the last numeric component of - the tag. - - For Git projects, the version relies on `git describe `_, - so you will see an additional ``g`` prepended to the ``{revision hash}``. - - Semantic Versioning (SemVer) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Due to the default behavior it's necessary to always include a - patch version (the ``3`` in ``1.2.3``), or else the automatic guessing - will increment the wrong part of the SemVer (e.g. tag ``2.0`` results in - ``2.1.devX`` instead of ``2.0.1.devX``). So please make sure to tag - accordingly. - - .. note:: - - Future versions of ``setuptools_scm`` will switch to `SemVer - `_ by default hiding the the old behavior as an - configurable option. - - - Builtin mechanisms for obtaining version numbers - -------------------------------------------------- - - 1. the SCM itself (git/hg) - 2. ``.hg_archival`` files (mercurial archives) - 3. ``PKG-INFO`` - - .. note:: - - Git archives are not supported due to Git shortcomings - - - File finders hook makes most of MANIFEST.in unnecessary - ------------------------------------------------------- - - ``setuptools_scm`` implements a `file_finders - `_ - entry point which returns all files tracked by your SCM. This eliminates - the need for a manually constructed ``MANIFEST.in`` in most cases where this - would be required when not using ``setuptools_scm``, namely: - - * To ensure all relevant files are packaged when running the ``sdist`` command. - - * When using `include_package_data `_ - to include package data as part of the ``build`` or ``bdist_wheel``. - - ``MANIFEST.in`` may still be used: anything defined there overrides the hook. - This is mostly useful to exclude files tracked in your SCM from packages, - although in principle it can be used to explicitly include non-tracked files - too. - - - Configuration parameters - ------------------------ - - In order to configure the way ``use_scm_version`` works you can provide - a mapping with options instead of a boolean value. - - The currently supported configuration keys are: - - :root: - Relative path to cwd, used for finding the SCM root; defaults to ``.`` - - :version_scheme: - Configures how the local version number is constructed; either an - entrypoint name or a callable. - - :local_scheme: - Configures how the local component of the version is constructed; either an - entrypoint name or a callable. - - :write_to: - A path to a file that gets replaced with a file containing the current - version. It is ideal for creating a ``version.py`` file within the - package, typically used to avoid using `pkg_resources.get_distribution` - (which adds some overhead). - - .. warning:: - - Only files with :code:`.py` and :code:`.txt` extensions have builtin - templates, for other file types it is necessary to provide - :code:`write_to_template`. - - :write_to_template: - A newstyle format string that is given the current version as - the ``version`` keyword argument for formatting. - - :relative_to: - A file from which the root can be resolved. - Typically called by a script or module that is not in the root of the - repository to point ``setuptools_scm`` at the root of the repository by - supplying ``__file__``. - - :tag_regex: - A Python regex string to extract the version part from any SCM tag. - The regex needs to contain either a single match group, or a group - named ``version``, that captures the actual version information. - - Defaults to the value of ``setuptools_scm.config.DEFAULT_TAG_REGEX`` - (see `config.py `_). - - :parentdir_prefix_version: - If the normal methods for detecting the version (SCM version, - sdist metadata) fail, and the parent directory name starts with - ``parentdir_prefix_version``, then this prefix is stripped and the rest of - the parent directory name is matched with ``tag_regex`` to get a version - string. If this parameter is unset (the default), then this fallback is - not used. - - This is intended to cover GitHub's "release tarballs", which extract into - directories named ``projectname-tag/`` (in which case - ``parentdir_prefix_version`` can be set e.g. to ``projectname-``). - - :fallback_version: - A version string that will be used if no other method for detecting the - version worked (e.g., when using a tarball with no metadata). If this is - unset (the default), setuptools_scm will error if it fails to detect the - version. - - :parse: - A function that will be used instead of the discovered SCM for parsing the - version. - Use with caution, this is a function for advanced use, and you should be - familiar with the ``setuptools_scm`` internals to use it. - - :git_describe_command: - This command will be used instead the default ``git describe`` command. - Use with caution, this is a function for advanced use, and you should be - familiar with the ``setuptools_scm`` internals to use it. - - Defaults to the value set by ``setuptools_scm.git.DEFAULT_DESCRIBE`` - (see `git.py `_). - - To use ``setuptools_scm`` in other Python code you can use the ``get_version`` - function: - - .. code:: python - - from setuptools_scm import get_version - my_version = get_version() - - It optionally accepts the keys of the ``use_scm_version`` parameter as - keyword arguments. - - Example configuration in ``setup.py`` format: - - .. code:: python - - from setuptools import setup - - setup( - use_scm_version={ - 'write_to': 'version.py', - 'write_to_template': '__version__ = "{version}"', - 'tag_regex': r'^(?Pv)?(?P[^\+]+)(?P.*)?$', - } - ) - - Environment variables - --------------------- - - :SETUPTOOLS_SCM_PRETEND_VERSION: - when defined and not empty, - its used as the primary source for the version number - in which case it will be a unparsed string - - :SETUPTOOLS_SCM_DEBUG: - when defined and not empty, - a lot of debug information will be printed as part of ``setuptools_scm`` - operating - - Extending setuptools_scm - ------------------------ - - ``setuptools_scm`` ships with a few ``setuptools`` entrypoints based hooks to - extend its default capabilities. - - Adding a new SCM - ~~~~~~~~~~~~~~~~ - - ``setuptools_scm`` provides two entrypoints for adding new SCMs: - - ``setuptools_scm.parse_scm`` - A function used to parse the metadata of the current workdir - using the name of the control directory/file of your SCM as the - entrypoint's name. E.g. for the built-in entrypoint for git the - entrypoint is named ``.git`` and references ``setuptools_scm.git:parse`` - - The return value MUST be a ``setuptools_scm.version.ScmVersion`` instance - created by the function ``setuptools_scm.version:meta``. - - ``setuptools_scm.files_command`` - Either a string containing a shell command that prints all SCM managed - files in its current working directory or a callable, that given a - pathname will return that list. - - Also use then name of your SCM control directory as name of the entrypoint. - - Version number construction - ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - ``setuptools_scm.version_scheme`` - Configures how the version number is constructed given a - ``setuptools_scm.version.ScmVersion`` instance and should return a string - representing the version. - - Available implementations: - - :guess-next-dev: Automatically guesses the next development version (default). - Guesses the upcoming release by incrementing the pre-release segment if present, - otherwise by incrementing the micro segment. Then appends :code:`.devN`. - :post-release: generates post release versions (adds :code:`.postN`) - :python-simplified-semver: Basic semantic versioning. Guesses the upcoming release - by incrementing the minor segment and setting the micro segment to zero if the - current branch contains the string ``'feature'``, otherwise by incrementing the - micro version. Then appends :code:`.devN`. Not compatible with pre-releases. - :release-branch-semver: Semantic versioning for projects with release branches. The - same as ``guess-next-dev`` (incrementing the pre-release or micro segment) if on - a release branch: a branch whose name (ignoring namespace) parses as a version - that matches the most recent tag up to the minor segment. Otherwise if on a - non-release branch, increments the minor segment and sets the micro segment to - zero, then appends :code:`.devN`. - - ``setuptools_scm.local_scheme`` - Configures how the local part of a version is rendered given a - ``setuptools_scm.version.ScmVersion`` instance and should return a string - representing the local version. - Dates and times are in Coordinated Universal Time (UTC), because as part - of the version, they should be location independent. - - Available implementations: - - :node-and-date: adds the node on dev versions and the date on dirty - workdir (default) - :node-and-timestamp: like ``node-and-date`` but with a timestamp of - the form ``{:%Y%m%d%H%M%S}`` instead - :dirty-tag: adds ``+dirty`` if the current workdir has changes - :no-local-version: omits local version, useful e.g. because pypi does - not support it - - - Importing in ``setup.py`` - ~~~~~~~~~~~~~~~~~~~~~~~~~ - - To support usage in ``setup.py`` passing a callable into ``use_scm_version`` - is supported. - - Within that callable, ``setuptools_scm`` is available for import. - The callable must return the configuration. - - - .. code:: python - - # content of setup.py - import setuptools - - def myversion(): - from setuptools_scm.version import get_local_dirty_tag - def clean_scheme(version): - return get_local_dirty_tag(version) if version.dirty else '+clean' - - return {'local_scheme': clean_scheme} - - setup( - ..., - use_scm_version=myversion, - ... - ) - - - Note on testing non-installed versions - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - While the general advice is to test against a installed version, - some environments require a test prior to install, - - .. code:: - - $ python setup.py egg_info - $ PYTHONPATH=$PWD:$PWD/src pytest - - - Interaction with Enterprise Distributions - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - Some enterprise distributions like RHEL7 and others - ship rather old setuptools versions due to various release management details. - - On such distributions one might observe errors like: - - :code:``setuptools_scm.version.SetuptoolsOutdatedWarning: your setuptools is too old (<12)`` - - In those case its typically possible to build by using a sdist against ``setuptools_scm<2.0``. - As those old setuptools versions lack sensible types for versions, - modern setuptools_scm is unable to support them sensibly. - - In case the project you need to build can not be patched to either use old setuptools_scm, - its still possible to install a more recent version of setuptools in order to handle the build - and/or install the package by using wheels or eggs. - - - - - Code of Conduct - --------------- - - Everyone interacting in the ``setuptools_scm`` project's codebases, issue - trackers, chat rooms, and mailing lists is expected to follow the - `PyPA Code of Conduct`_. - - .. _PyPA Code of Conduct: https://www.pypa.io/en/latest/code-of-conduct/ - - Security Contact - ================ - - To report a security vulnerability, please use the - `Tidelift security contact `_. - Tidelift will coordinate the fix and disclosure. - -Platform: UNKNOWN -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved :: MIT License -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 2.7 -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.5 -Classifier: Programming Language :: Python :: 3.6 -Classifier: Programming Language :: Python :: 3.7 -Classifier: Programming Language :: Python :: 3.8 -Classifier: Topic :: Software Development :: Libraries -Classifier: Topic :: Software Development :: Version Control -Classifier: Topic :: System :: Software Distribution -Classifier: Topic :: Utilities -Requires-Python: !=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7 -Provides-Extra: toml diff --git a/src/setuptools_scm.egg-info/SOURCES.txt b/src/setuptools_scm.egg-info/SOURCES.txt deleted file mode 100644 index 3953c8e..0000000 --- a/src/setuptools_scm.egg-info/SOURCES.txt +++ /dev/null @@ -1,47 +0,0 @@ -.gitignore -CHANGELOG.rst -LICENSE -MANIFEST.in -README.rst -pyproject.toml -setup.cfg -setup.py -tox.ini -.github/FUNDING.yml -.github/workflows/pre-commit.yml -.github/workflows/python-tests.yml -src/setuptools_scm/__init__.py -src/setuptools_scm/__main__.py -src/setuptools_scm/config.py -src/setuptools_scm/discover.py -src/setuptools_scm/file_finder.py -src/setuptools_scm/file_finder_git.py -src/setuptools_scm/file_finder_hg.py -src/setuptools_scm/git.py -src/setuptools_scm/hacks.py -src/setuptools_scm/hg.py -src/setuptools_scm/integration.py -src/setuptools_scm/utils.py -src/setuptools_scm/version.py -src/setuptools_scm/win_py31_compat.py -src/setuptools_scm.egg-info/PKG-INFO -src/setuptools_scm.egg-info/SOURCES.txt -src/setuptools_scm.egg-info/dependency_links.txt -src/setuptools_scm.egg-info/entry_points.txt -src/setuptools_scm.egg-info/requires.txt -src/setuptools_scm.egg-info/top_level.txt -src/setuptools_scm.egg-info/zip-safe -testing/check_self_install.py -testing/conftest.py -testing/play_out_381.bash -testing/test_basic_api.py -testing/test_config.py -testing/test_file_finder.py -testing/test_functions.py -testing/test_git.py -testing/test_integration.py -testing/test_main.py -testing/test_mercurial.py -testing/test_regressions.py -testing/test_setuptools_support.py -testing/test_version.py \ No newline at end of file diff --git a/src/setuptools_scm.egg-info/dependency_links.txt b/src/setuptools_scm.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/setuptools_scm.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/setuptools_scm.egg-info/entry_points.txt b/src/setuptools_scm.egg-info/entry_points.txt deleted file mode 100644 index 7e46afc..0000000 --- a/src/setuptools_scm.egg-info/entry_points.txt +++ /dev/null @@ -1,35 +0,0 @@ -[distutils.setup_keywords] -use_scm_version = setuptools_scm.integration:version_keyword - -[setuptools.file_finders] -setuptools_scm = setuptools_scm.integration:find_files - -[setuptools.finalize_distribution_options] -setuptools_scm = setuptools_scm.integration:infer_version - -[setuptools_scm.files_command] -.git = setuptools_scm.file_finder_git:git_find_files -.hg = setuptools_scm.file_finder_hg:hg_find_files - -[setuptools_scm.local_scheme] -dirty-tag = setuptools_scm.version:get_local_dirty_tag -no-local-version = setuptools_scm.version:get_no_local_node -node-and-date = setuptools_scm.version:get_local_node_and_date -node-and-timestamp = setuptools_scm.version:get_local_node_and_timestamp - -[setuptools_scm.parse_scm] -.git = setuptools_scm.git:parse -.hg = setuptools_scm.hg:parse - -[setuptools_scm.parse_scm_fallback] -.hg_archival.txt = setuptools_scm.hg:parse_archival -PKG-INFO = setuptools_scm.hacks:parse_pkginfo -pip-egg-info = setuptools_scm.hacks:parse_pip_egg_info -setup.py = setuptools_scm.hacks:fallback_version - -[setuptools_scm.version_scheme] -guess-next-dev = setuptools_scm.version:guess_next_dev_version -post-release = setuptools_scm.version:postrelease_version -python-simplified-semver = setuptools_scm.version:simplified_semver_version -release-branch-semver = setuptools_scm.version:release_branch_semver_version - diff --git a/src/setuptools_scm.egg-info/requires.txt b/src/setuptools_scm.egg-info/requires.txt deleted file mode 100644 index 1fad750..0000000 --- a/src/setuptools_scm.egg-info/requires.txt +++ /dev/null @@ -1,4 +0,0 @@ -setuptools - -[toml] -toml diff --git a/src/setuptools_scm.egg-info/top_level.txt b/src/setuptools_scm.egg-info/top_level.txt deleted file mode 100644 index cba8d88..0000000 --- a/src/setuptools_scm.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -setuptools_scm diff --git a/src/setuptools_scm.egg-info/zip-safe b/src/setuptools_scm.egg-info/zip-safe deleted file mode 100644 index 8b13789..0000000 --- a/src/setuptools_scm.egg-info/zip-safe +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/setuptools_scm/.git_archival.txt b/src/setuptools_scm/.git_archival.txt new file mode 100644 index 0000000..560411b --- /dev/null +++ b/src/setuptools_scm/.git_archival.txt @@ -0,0 +1,4 @@ +node: e5986362078130fbefc5e7afb9760ba251c523b0 +node-date: 2023-12-18T16:30:45+09:00 +describe-name: %(describe:tags=true,match=*[0-9]*) +ref-names: HEAD -> master, origin/master, origin/HEAD diff --git a/src/setuptools_scm/__init__.py b/src/setuptools_scm/__init__.py index 6b22b28..aa40ab3 100644 --- a/src/setuptools_scm/__init__.py +++ b/src/setuptools_scm/__init__.py @@ -1,163 +1,30 @@ """ -:copyright: 2010-2015 by Ronny Pfannschmidt +:copyright: 2010-2023 by Ronny Pfannschmidt :license: MIT """ -import os -import warnings - -from .config import ( - Configuration, - DEFAULT_VERSION_SCHEME, - DEFAULT_LOCAL_SCHEME, - DEFAULT_TAG_REGEX, -) -from .utils import function_has_arg, string_types -from .version import format_version, meta -from .discover import iter_matching_entrypoints - -PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION" - -TEMPLATES = { - ".py": """\ -# coding: utf-8 -# file generated by setuptools_scm -# don't change, don't track in version control -version = {version!r} -""", - ".txt": "{version}", -} - - -def version_from_scm(root): - warnings.warn( - "version_from_scm is deprecated please use get_version", - category=DeprecationWarning, - ) - config = Configuration() - config.root = root - # TODO: Is it API? - return _version_from_entrypoints(config) - - -def _call_entrypoint_fn(root, config, fn): - if function_has_arg(fn, "config"): - return fn(root, config=config) - else: - warnings.warn( - "parse functions are required to provide a named argument" - " 'config' in the future.", - category=PendingDeprecationWarning, - stacklevel=2, - ) - return fn(root) - - -def _version_from_entrypoints(config, fallback=False): - if fallback: - entrypoint = "setuptools_scm.parse_scm_fallback" - root = config.fallback_root - else: - entrypoint = "setuptools_scm.parse_scm" - root = config.absolute_root - for ep in iter_matching_entrypoints(root, entrypoint): - version = _call_entrypoint_fn(root, config, ep.load()) - - if version: - return version - - -def dump_version(root, version, write_to, template=None): - assert isinstance(version, string_types) - if not write_to: - return - target = os.path.normpath(os.path.join(root, write_to)) - ext = os.path.splitext(target)[1] - template = template or TEMPLATES.get(ext) - - if template is None: - raise ValueError( - "bad file format: '{}' (of {}) \nonly *.txt and *.py are supported".format( - os.path.splitext(target)[1], target - ) - ) - with open(target, "w") as fp: - fp.write(template.format(version=version)) - - -def _do_parse(config): - pretended = os.environ.get(PRETEND_KEY) - if pretended: - # we use meta here since the pretended version - # must adhere to the pep to begin with - return meta(tag=pretended, preformatted=True, config=config) - - if config.parse: - parse_result = _call_entrypoint_fn(config.absolute_root, config, config.parse) - if isinstance(parse_result, string_types): - raise TypeError( - "version parse result was a string\nplease return a parsed version" - ) - version = parse_result or _version_from_entrypoints(config, fallback=True) - else: - # include fallbacks after dropping them from the main entrypoint - version = _version_from_entrypoints(config) or _version_from_entrypoints( - config, fallback=True - ) - - if version: - return version - - raise LookupError( - "setuptools-scm was unable to detect version for %r.\n\n" - "Make sure you're either building from a fully intact git repository " - "or PyPI tarballs. Most other sources (such as GitHub's tarballs, a " - "git checkout without the .git folder) don't contain the necessary " - "metadata and will not work.\n\n" - "For example, if you're using pip, instead of " - "https://github.com/user/proj/archive/master.zip " - "use git+https://github.com/user/proj.git#egg=proj" % config.absolute_root - ) - - -def get_version( - root=".", - version_scheme=DEFAULT_VERSION_SCHEME, - local_scheme=DEFAULT_LOCAL_SCHEME, - write_to=None, - write_to_template=None, - relative_to=None, - tag_regex=DEFAULT_TAG_REGEX, - parentdir_prefix_version=None, - fallback_version=None, - fallback_root=".", - parse=None, - git_describe_command=None, -): - """ - If supplied, relative_to should be a file from which root may - be resolved. Typically called by a script or module that is not - in the root of the repository to direct setuptools_scm to the - root of the repository by supplying ``__file__``. - """ - - config = Configuration(**locals()) - return _get_version(config) - - -def _get_version(config): - parsed_version = _do_parse(config) - - if parsed_version: - version_string = format_version( - parsed_version, - version_scheme=config.version_scheme, - local_scheme=config.local_scheme, - ) - dump_version( - root=config.root, - version=version_string, - write_to=config.write_to, - template=config.write_to_template, - ) - - return version_string +from __future__ import annotations + +from ._config import Configuration +from ._config import DEFAULT_LOCAL_SCHEME # soft deprecated +from ._config import DEFAULT_VERSION_SCHEME # soft deprecated +from ._get_version_impl import _get_version # soft deprecated +from ._get_version_impl import get_version # soft deprecated +from ._integration.dump_version import dump_version # soft deprecated +from ._version_cls import NonNormalizedVersion +from ._version_cls import Version +from .version import ScmVersion + + +# Public API +__all__ = [ + # soft deprecated imports, left for backward compatibility + "get_version", + "_get_version", + "dump_version", + "DEFAULT_VERSION_SCHEME", + "DEFAULT_LOCAL_SCHEME", + "Configuration", + "Version", + "ScmVersion", + "NonNormalizedVersion", +] diff --git a/src/setuptools_scm/__main__.py b/src/setuptools_scm/__main__.py index a464c51..dab6068 100644 --- a/src/setuptools_scm/__main__.py +++ b/src/setuptools_scm/__main__.py @@ -1,17 +1,6 @@ -from __future__ import print_function -import sys -from setuptools_scm import get_version -from setuptools_scm.integration import find_files -from setuptools_scm.version import _warn_if_setuptools_outdated - - -def main(): - _warn_if_setuptools_outdated() - print("Guessed Version", get_version()) - if "ls" in sys.argv: - for fname in find_files("."): - print(fname) +from __future__ import annotations +from ._cli import main if __name__ == "__main__": main() diff --git a/src/setuptools_scm/_cli.py b/src/setuptools_scm/_cli.py new file mode 100644 index 0000000..66099b1 --- /dev/null +++ b/src/setuptools_scm/_cli.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import argparse +import os +import sys + +from setuptools_scm import Configuration +from setuptools_scm._file_finders import find_files +from setuptools_scm._get_version_impl import _get_version +from setuptools_scm.discover import walk_potential_roots + + +def main(args: list[str] | None = None) -> None: + opts = _get_cli_opts(args) + inferred_root: str = opts.root or "." + + pyproject = opts.config or _find_pyproject(inferred_root) + + try: + config = Configuration.from_file( + pyproject, + root=(os.path.abspath(opts.root) if opts.root is not None else None), + ) + except (LookupError, FileNotFoundError) as ex: + # no pyproject.toml OR no [tool.setuptools_scm] + print( + f"Warning: could not use {os.path.relpath(pyproject)}," + " using default configuration.\n" + f" Reason: {ex}.", + file=sys.stderr, + ) + config = Configuration(inferred_root) + + version = _get_version(config, force_write_version_files=False) + if version is None: + raise SystemExit("ERROR: no version found for", opts) + if opts.strip_dev: + version = version.partition(".dev")[0] + print(version) + + if opts.command == "ls": + for fname in find_files(config.root): + print(fname) + + +def _get_cli_opts(args: list[str] | None) -> argparse.Namespace: + prog = "python -m setuptools_scm" + desc = "Print project version according to SCM metadata" + parser = argparse.ArgumentParser(prog, description=desc) + # By default, help for `--help` starts with lower case, so we keep the pattern: + parser.add_argument( + "-r", + "--root", + default=None, + help='directory managed by the SCM, default: inferred from config file, or "."', + ) + parser.add_argument( + "-c", + "--config", + default=None, + metavar="PATH", + help="path to 'pyproject.toml' with setuptools_scm config, " + "default: looked up in the current or parent directories", + ) + parser.add_argument( + "--strip-dev", + action="store_true", + help="remove the dev/local parts of the version before printing the version", + ) + sub = parser.add_subparsers(title="extra commands", dest="command", metavar="") + # We avoid `metavar` to prevent printing repetitive information + desc = "List files managed by the SCM" + sub.add_parser("ls", help=desc[0].lower() + desc[1:], description=desc) + return parser.parse_args(args) + + +def _find_pyproject(parent: str) -> str: + for directory in walk_potential_roots(os.path.abspath(parent)): + pyproject = os.path.join(directory, "pyproject.toml") + if os.path.isfile(pyproject): + return pyproject + + return os.path.abspath( + "pyproject.toml" + ) # use default name to trigger the default errors diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py new file mode 100644 index 0000000..5e5feb1 --- /dev/null +++ b/src/setuptools_scm/_config.py @@ -0,0 +1,151 @@ +""" configuration """ +from __future__ import annotations + +import dataclasses +import os +import re +import warnings +from pathlib import Path +from typing import Any +from typing import Pattern +from typing import Protocol + +from . import _log +from . import _types as _t +from ._integration.pyproject_reading import ( + get_args_for_pyproject as _get_args_for_pyproject, +) +from ._integration.pyproject_reading import read_pyproject as _read_pyproject +from ._overrides import read_toml_overrides +from ._version_cls import _validate_version_cls +from ._version_cls import _VersionT +from ._version_cls import Version as _Version + +log = _log.log.getChild("config") + +DEFAULT_TAG_REGEX = re.compile( + r"^(?:[\w-]+-)?(?P[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$" +) +"""default tag regex that tries to match PEP440 style versions +with prefix consisting of dashed words""" + +DEFAULT_VERSION_SCHEME = "guess-next-dev" +DEFAULT_LOCAL_SCHEME = "node-and-date" + + +def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]: + if not value: + regex = DEFAULT_TAG_REGEX + else: + regex = re.compile(value) + + group_names = regex.groupindex.keys() + if regex.groups == 0 or (regex.groups > 1 and "version" not in group_names): + warnings.warn( + "Expected tag_regex to contain a single match group or a group named" + " 'version' to identify the version part of any tag." + ) + + return regex + + +class ParseFunction(Protocol): + def __call__( + self, root: _t.PathT, *, config: Configuration + ) -> _t.SCMVERSION | None: + ... + + +def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str: + log.debug("check absolute root=%s relative_to=%s", root, relative_to) + if relative_to: + if ( + os.path.isabs(root) + and os.path.isabs(relative_to) + and not os.path.commonpath([root, relative_to]) == root + ): + warnings.warn( + f"absolute root path '{root}' overrides relative_to '{relative_to}'" + ) + if os.path.isdir(relative_to): + warnings.warn( + "relative_to is expected to be a file," + f" its the directory {relative_to}\n" + "assuming the parent directory was passed" + ) + log.debug("dir %s", relative_to) + root = os.path.join(relative_to, root) + else: + log.debug("file %s", relative_to) + root = os.path.join(os.path.dirname(relative_to), root) + return os.path.abspath(root) + + +@dataclasses.dataclass +class Configuration: + """Global configuration model""" + + relative_to: _t.PathT | None = None + root: _t.PathT = "." + version_scheme: _t.VERSION_SCHEME = DEFAULT_VERSION_SCHEME + local_scheme: _t.VERSION_SCHEME = DEFAULT_LOCAL_SCHEME + tag_regex: Pattern[str] = DEFAULT_TAG_REGEX + parentdir_prefix_version: str | None = None + fallback_version: str | None = None + fallback_root: _t.PathT = "." + write_to: _t.PathT | None = None + write_to_template: str | None = None + version_file: _t.PathT | None = None + version_file_template: str | None = None + parse: ParseFunction | None = None + git_describe_command: _t.CMD_TYPE | None = None + dist_name: str | None = None + version_cls: type[_VersionT] = _Version + search_parent_directories: bool = False + + parent: _t.PathT | None = None + + @property + def absolute_root(self) -> str: + return _check_absolute_root(self.root, self.relative_to) + + @classmethod + def from_file( + cls, + name: str | os.PathLike[str] = "pyproject.toml", + dist_name: str | None = None, + _require_section: bool = True, + **kwargs: Any, + ) -> Configuration: + """ + Read Configuration from pyproject.toml (or similar). + Raises exceptions when file is not found or toml is + not installed or the file has invalid format or does + not contain the [tool.setuptools_scm] section. + """ + + pyproject_data = _read_pyproject(Path(name), require_section=_require_section) + args = _get_args_for_pyproject(pyproject_data, dist_name, kwargs) + + args.update(read_toml_overrides(args["dist_name"])) + relative_to = args.pop("relative_to", name) + return cls.from_data(relative_to=relative_to, data=args) + + @classmethod + def from_data( + cls, relative_to: str | os.PathLike[str], data: dict[str, Any] + ) -> Configuration: + """ + given configuration data + create a config instance after validating tag regex/version class + """ + tag_regex = _check_tag_regex(data.pop("tag_regex", None)) + version_cls = _validate_version_cls( + data.pop("version_cls", None), data.pop("normalize", True) + ) + return cls( + relative_to, + version_cls=version_cls, + tag_regex=tag_regex, + **data, + ) diff --git a/src/setuptools_scm/_entrypoints.py b/src/setuptools_scm/_entrypoints.py new file mode 100644 index 0000000..50c9182 --- /dev/null +++ b/src/setuptools_scm/_entrypoints.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import sys +from typing import Any +from typing import Callable +from typing import cast +from typing import Iterator +from typing import overload +from typing import TYPE_CHECKING + +from . import _log +from . import version + +if TYPE_CHECKING: + from . import _types as _t + from ._config import Configuration, ParseFunction + + +from importlib.metadata import EntryPoint as EntryPoint + + +if sys.version_info[:2] < (3, 10): + from importlib.metadata import entry_points as legacy_entry_points + + class EntryPoints: + _groupdata: list[EntryPoint] + + def __init__(self, groupdata: list[EntryPoint]) -> None: + self._groupdata = groupdata + + def select(self, name: str) -> EntryPoints: + return EntryPoints([x for x in self._groupdata if x.name == name]) + + def __iter__(self) -> Iterator[EntryPoint]: + return iter(self._groupdata) + + def entry_points(group: str) -> EntryPoints: + return EntryPoints(legacy_entry_points()[group]) + +else: + from importlib.metadata import entry_points, EntryPoints + + +log = _log.log.getChild("entrypoints") + + +def version_from_entrypoint( + config: Configuration, *, entrypoint: str, root: _t.PathT +) -> version.ScmVersion | None: + from .discover import iter_matching_entrypoints + + log.debug("version_from_ep %s in %s", entrypoint, root) + for ep in iter_matching_entrypoints(root, entrypoint, config): + fn: ParseFunction = ep.load() + maybe_version: version.ScmVersion | None = fn(root, config=config) + log.debug("%s found %r", ep, maybe_version) + if maybe_version is not None: + return maybe_version + return None + + +def iter_entry_points(group: str, name: str | None = None) -> Iterator[EntryPoint]: + eps: EntryPoints = entry_points(group=group) + res = eps if name is None else eps.select(name=name) + + return iter(res) + + +def _get_ep(group: str, name: str) -> Any | None: + for ep in iter_entry_points(group, name): + log.debug("ep found: %s", ep.name) + return ep.load() + else: + return None + + +def _get_from_object_reference_str(path: str, group: str) -> Any | None: + # todo: remove for importlib native spelling + ep = EntryPoint(path, path, group) + try: + return ep.load() + except (AttributeError, ModuleNotFoundError): + return None + + +def _iter_version_schemes( + entrypoint: str, + scheme_value: _t.VERSION_SCHEMES, + _memo: set[object] | None = None, +) -> Iterator[Callable[[version.ScmVersion], str]]: + if _memo is None: + _memo = set() + if isinstance(scheme_value, str): + scheme_value = cast( + "_t.VERSION_SCHEMES", + _get_ep(entrypoint, scheme_value) + or _get_from_object_reference_str(scheme_value, entrypoint), + ) + + if isinstance(scheme_value, (list, tuple)): + for variant in scheme_value: + if variant not in _memo: + _memo.add(variant) + yield from _iter_version_schemes(entrypoint, variant, _memo=_memo) + elif callable(scheme_value): + yield scheme_value + + +@overload +def _call_version_scheme( + version: version.ScmVersion, + entrypoint: str, + given_value: _t.VERSION_SCHEMES, + default: str, +) -> str: + ... + + +@overload +def _call_version_scheme( + version: version.ScmVersion, + entrypoint: str, + given_value: _t.VERSION_SCHEMES, + default: None, +) -> str | None: + ... + + +def _call_version_scheme( + version: version.ScmVersion, + entrypoint: str, + given_value: _t.VERSION_SCHEMES, + default: str | None, +) -> str | None: + for scheme in _iter_version_schemes(entrypoint, given_value): + result = scheme(version) + if result is not None: + return result + return default diff --git a/src/setuptools_scm/_file_finders/__init__.py b/src/setuptools_scm/_file_finders/__init__.py new file mode 100644 index 0000000..591aa90 --- /dev/null +++ b/src/setuptools_scm/_file_finders/__init__.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import itertools +import os +from typing import Callable +from typing import TYPE_CHECKING + +from .. import _log +from .. import _types as _t +from .._entrypoints import iter_entry_points + +if TYPE_CHECKING: + from typing_extensions import TypeGuard + + +log = _log.log.getChild("file_finder") + + +def scm_find_files( + path: _t.PathT, + scm_files: set[str], + scm_dirs: set[str], + force_all_files: bool = False, +) -> list[str]: + """ setuptools compatible file finder that follows symlinks + + - path: the root directory from which to search + - scm_files: set of scm controlled files and symlinks + (including symlinks to directories) + - scm_dirs: set of scm controlled directories + (including directories containing no scm controlled files) + - force_all_files: ignore ``scm_files`` and ``scm_dirs`` and list everything. + + scm_files and scm_dirs must be absolute with symlinks resolved (realpath), + with normalized case (normcase) + + Spec here: http://setuptools.readthedocs.io/en/latest/setuptools.html#\ + adding-support-for-revision-control-systems + """ + realpath = os.path.normcase(os.path.realpath(path)) + seen: set[str] = set() + res: list[str] = [] + for dirpath, dirnames, filenames in os.walk(realpath, followlinks=True): + # dirpath with symlinks resolved + realdirpath = os.path.normcase(os.path.realpath(dirpath)) + + def _link_not_in_scm(n: str, realdirpath: str = realdirpath) -> bool: + fn = os.path.join(realdirpath, os.path.normcase(n)) + return os.path.islink(fn) and fn not in scm_files + + if not force_all_files and realdirpath not in scm_dirs: + # directory not in scm, don't walk it's content + dirnames[:] = [] + continue + if os.path.islink(dirpath) and not os.path.relpath( + realdirpath, realpath + ).startswith(os.pardir): + # a symlink to a directory not outside path: + # we keep it in the result and don't walk its content + res.append(os.path.join(path, os.path.relpath(dirpath, path))) + dirnames[:] = [] + continue + if realdirpath in seen: + # symlink loop protection + dirnames[:] = [] + continue + dirnames[:] = [ + dn for dn in dirnames if force_all_files or not _link_not_in_scm(dn) + ] + for filename in filenames: + if not force_all_files and _link_not_in_scm(filename): + continue + # dirpath + filename with symlinks preserved + fullfilename = os.path.join(dirpath, filename) + is_tracked = os.path.normcase(os.path.realpath(fullfilename)) in scm_files + if force_all_files or is_tracked: + res.append(os.path.join(path, os.path.relpath(fullfilename, realpath))) + seen.add(realdirpath) + return res + + +def is_toplevel_acceptable(toplevel: str | None) -> TypeGuard[str]: + """ """ + if toplevel is None: + return False + + ignored: list[str] = os.environ.get("SETUPTOOLS_SCM_IGNORE_VCS_ROOTS", "").split( + os.pathsep + ) + ignored = [os.path.normcase(p) for p in ignored] + + log.debug("toplevel: %r\n ignored %s", toplevel, ignored) + + return toplevel not in ignored + + +def find_files(path: _t.PathT = "") -> list[str]: + for ep in itertools.chain( + iter_entry_points("setuptools_scm.files_command"), + iter_entry_points("setuptools_scm.files_command_fallback"), + ): + command: Callable[[_t.PathT], list[str]] = ep.load() + res: list[str] = command(path) + if res: + return res + return [] diff --git a/src/setuptools_scm/_file_finders/git.py b/src/setuptools_scm/_file_finders/git.py new file mode 100644 index 0000000..873b4ba --- /dev/null +++ b/src/setuptools_scm/_file_finders/git.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import logging +import os +import subprocess +import tarfile +from typing import IO + +from . import is_toplevel_acceptable +from . import scm_find_files +from .. import _types as _t +from .._run_cmd import run as _run +from ..integration import data_from_mime + +log = logging.getLogger(__name__) + + +def _git_toplevel(path: str) -> str | None: + try: + cwd = os.path.abspath(path or ".") + res = _run(["git", "rev-parse", "HEAD"], cwd=cwd) + if res.returncode: + # BAIL if there is no commit + log.error("listing git files failed - pretending there aren't any") + return None + res = _run( + ["git", "rev-parse", "--show-prefix"], + cwd=cwd, + ) + if res.returncode: + return None + out = res.stdout[:-1] # remove the trailing pathsep + if not out: + out = cwd + else: + # Here, ``out`` is a relative path to root of git. + # ``cwd`` is absolute path to current working directory. + # the below method removes the length of ``out`` from + # ``cwd``, which gives the git toplevel + assert cwd.replace("\\", "/").endswith(out), f"cwd={cwd!r}\nout={out!r}" + # In windows cwd contains ``\`` which should be replaced by ``/`` + # for this assertion to work. Length of string isn't changed by replace + # ``\\`` is just and escape for `\` + out = cwd[: -len(out)] + log.debug("find files toplevel %s", out) + return os.path.normcase(os.path.realpath(out.strip())) + except subprocess.CalledProcessError: + # git returned error, we are not in a git repo + return None + except OSError: + # git command not found, probably + return None + + +def _git_interpret_archive(fd: IO[bytes], toplevel: str) -> tuple[set[str], set[str]]: + with tarfile.open(fileobj=fd, mode="r|*") as tf: + git_files = set() + git_dirs = {toplevel} + for member in tf.getmembers(): + name = os.path.normcase(member.name).replace("/", os.path.sep) + if member.type == tarfile.DIRTYPE: + git_dirs.add(name) + else: + git_files.add(name) + return git_files, git_dirs + + +def _git_ls_files_and_dirs(toplevel: str) -> tuple[set[str], set[str]]: + # use git archive instead of git ls-file to honor + # export-ignore git attribute + + cmd = ["git", "archive", "--prefix", toplevel + os.path.sep, "HEAD"] + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, cwd=toplevel, stderr=subprocess.DEVNULL + ) + assert proc.stdout is not None + try: + try: + return _git_interpret_archive(proc.stdout, toplevel) + finally: + # ensure we avoid resource warnings by cleaning up the process + proc.stdout.close() + proc.terminate() + except Exception: + if proc.wait() != 0: + log.error("listing git files failed - pretending there aren't any") + return set(), set() + + +def git_find_files(path: _t.PathT = "") -> list[str]: + toplevel = _git_toplevel(os.fspath(path)) + if not is_toplevel_acceptable(toplevel): + return [] + fullpath = os.path.abspath(os.path.normpath(path)) + if not fullpath.startswith(toplevel): + log.warning("toplevel mismatch computed %s vs resolved %s ", toplevel, fullpath) + git_files, git_dirs = _git_ls_files_and_dirs(toplevel) + return scm_find_files(path, git_files, git_dirs) + + +def git_archive_find_files(path: _t.PathT = "") -> list[str]: + # This function assumes that ``path`` is obtained from a git archive + # and therefore all the files that should be ignored were already removed. + archival = os.path.join(path, ".git_archival.txt") + if not os.path.exists(archival): + return [] + + data = data_from_mime(archival) + + if "$Format" in data.get("node", ""): + # Substitutions have not been performed, so not a reliable archive + return [] + + log.warning("git archive detected - fallback to listing all files") + return scm_find_files(path, set(), set(), force_all_files=True) diff --git a/src/setuptools_scm/_file_finders/hg.py b/src/setuptools_scm/_file_finders/hg.py new file mode 100644 index 0000000..f87ba06 --- /dev/null +++ b/src/setuptools_scm/_file_finders/hg.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +import os +import subprocess + +from .. import _types as _t +from .._file_finders import is_toplevel_acceptable +from .._file_finders import scm_find_files +from .._run_cmd import run as _run +from ..integration import data_from_mime + +log = logging.getLogger(__name__) + + +def _hg_toplevel(path: str) -> str | None: + try: + res = _run( + ["hg", "root"], + cwd=(path or "."), + ) + res.check_returncode() + return os.path.normcase(os.path.realpath(res.stdout)) + except subprocess.CalledProcessError: + # hg returned error, we are not in a mercurial repo + return None + except OSError: + # hg command not found, probably + return None + + +def _hg_ls_files_and_dirs(toplevel: str) -> tuple[set[str], set[str]]: + hg_files: set[str] = set() + hg_dirs = {toplevel} + res = _run(["hg", "files"], cwd=toplevel) + if res.returncode: + return set(), set() + for name in res.stdout.splitlines(): + name = os.path.normcase(name).replace("/", os.path.sep) + fullname = os.path.join(toplevel, name) + hg_files.add(fullname) + dirname = os.path.dirname(fullname) + while len(dirname) > len(toplevel) and dirname not in hg_dirs: + hg_dirs.add(dirname) + dirname = os.path.dirname(dirname) + return hg_files, hg_dirs + + +def hg_find_files(path: str = "") -> list[str]: + toplevel = _hg_toplevel(path) + if not is_toplevel_acceptable(toplevel): + return [] + assert toplevel is not None + hg_files, hg_dirs = _hg_ls_files_and_dirs(toplevel) + return scm_find_files(path, hg_files, hg_dirs) + + +def hg_archive_find_files(path: _t.PathT = "") -> list[str]: + # This function assumes that ``path`` is obtained from a mercurial archive + # and therefore all the files that should be ignored were already removed. + archival = os.path.join(path, ".hg_archival.txt") + if not os.path.exists(archival): + return [] + + data = data_from_mime(archival) + + if "node" not in data: + # Ensure file is valid + return [] + + log.warning("hg archive detected - fallback to listing all files") + return scm_find_files(path, set(), set(), force_all_files=True) diff --git a/src/setuptools_scm/_get_version_impl.py b/src/setuptools_scm/_get_version_impl.py new file mode 100644 index 0000000..2d9d947 --- /dev/null +++ b/src/setuptools_scm/_get_version_impl.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import logging +import re +import warnings +from pathlib import Path +from typing import Any +from typing import NoReturn +from typing import Pattern + +from . import _config +from . import _entrypoints +from . import _run_cmd +from . import _types as _t +from ._config import Configuration +from ._overrides import _read_pretended_version_for +from ._version_cls import _validate_version_cls +from .version import format_version as _format_version +from .version import ScmVersion + +_log = logging.getLogger(__name__) + + +def parse_scm_version(config: Configuration) -> ScmVersion | None: + try: + if config.parse is not None: + parse_result = config.parse(config.absolute_root, config=config) + if parse_result is not None and not isinstance(parse_result, ScmVersion): + raise TypeError( + f"version parse result was {str!r}\n" + "please return a parsed version (ScmVersion)" + ) + return parse_result + else: + return _entrypoints.version_from_entrypoint( + config, + entrypoint="setuptools_scm.parse_scm", + root=config.absolute_root, + ) + except _run_cmd.CommandNotFoundError as e: + _log.exception("command %s not found while parsing the scm, using fallbacks", e) + return None + + +def parse_fallback_version(config: Configuration) -> ScmVersion | None: + return _entrypoints.version_from_entrypoint( + config, + entrypoint="setuptools_scm.parse_scm_fallback", + root=config.fallback_root, + ) + + +def parse_version(config: Configuration) -> ScmVersion | None: + return ( + _read_pretended_version_for(config) + or parse_scm_version(config) + or parse_fallback_version(config) + ) + + +def write_version_files( + config: Configuration, version: str, scm_version: ScmVersion +) -> None: + if config.write_to is not None: + from ._integration.dump_version import dump_version + + dump_version( + root=config.root, + version=version, + scm_version=scm_version, + write_to=config.write_to, + template=config.write_to_template, + ) + if config.version_file: + from ._integration.dump_version import write_version_to_path + + version_file = Path(config.version_file) + assert not version_file.is_absolute(), f"{version_file=}" + # todo: use a better name than fallback root + assert config.relative_to is not None + target = Path(config.relative_to).parent.joinpath(version_file) + write_version_to_path( + target, + template=config.version_file_template, + version=version, + scm_version=scm_version, + ) + + +def _get_version( + config: Configuration, force_write_version_files: bool | None = None +) -> str | None: + parsed_version = parse_version(config) + if parsed_version is None: + return None + version_string = _format_version(parsed_version) + if force_write_version_files is None: + force_write_version_files = True + warnings.warn( + "force_write_version_files ought to be set," + " presuming the legacy True value", + DeprecationWarning, + ) + + if force_write_version_files: + write_version_files(config, version=version_string, scm_version=parsed_version) + + return version_string + + +def _version_missing(config: Configuration) -> NoReturn: + raise LookupError( + f"setuptools-scm was unable to detect version for {config.absolute_root}.\n\n" + "Make sure you're either building from a fully intact git repository " + "or PyPI tarballs. Most other sources (such as GitHub's tarballs, a " + "git checkout without the .git folder) don't contain the necessary " + "metadata and will not work.\n\n" + "For example, if you're using pip, instead of " + "https://github.com/user/proj/archive/master.zip " + "use git+https://github.com/user/proj.git#egg=proj" + ) + + +def get_version( + root: _t.PathT = ".", + version_scheme: _t.VERSION_SCHEME = _config.DEFAULT_VERSION_SCHEME, + local_scheme: _t.VERSION_SCHEME = _config.DEFAULT_LOCAL_SCHEME, + write_to: _t.PathT | None = None, + write_to_template: str | None = None, + version_file: _t.PathT | None = None, + version_file_template: str | None = None, + relative_to: _t.PathT | None = None, + tag_regex: str | Pattern[str] = _config.DEFAULT_TAG_REGEX, + parentdir_prefix_version: str | None = None, + fallback_version: str | None = None, + fallback_root: _t.PathT = ".", + parse: Any | None = None, + git_describe_command: _t.CMD_TYPE | None = None, + dist_name: str | None = None, + version_cls: Any | None = None, + normalize: bool = True, + search_parent_directories: bool = False, +) -> str: + """ + If supplied, relative_to should be a file from which root may + be resolved. Typically called by a script or module that is not + in the root of the repository to direct setuptools_scm to the + root of the repository by supplying ``__file__``. + """ + + version_cls = _validate_version_cls(version_cls, normalize) + del normalize + tag_regex = parse_tag_regex(tag_regex) + config = Configuration(**locals()) + maybe_version = _get_version(config, force_write_version_files=True) + + if maybe_version is None: + _version_missing(config) + return maybe_version + + +def parse_tag_regex(tag_regex: str | Pattern[str]) -> Pattern[str]: + if isinstance(tag_regex, str): + if tag_regex == "": + warnings.warn( + DeprecationWarning( + "empty regex for tag regex is invalid, using default" + ) + ) + return _config.DEFAULT_TAG_REGEX + else: + return re.compile(tag_regex) + else: + return tag_regex diff --git a/src/setuptools_scm/_integration/__init__.py b/src/setuptools_scm/_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/setuptools_scm/_integration/dump_version.py b/src/setuptools_scm/_integration/dump_version.py new file mode 100644 index 0000000..d890243 --- /dev/null +++ b/src/setuptools_scm/_integration/dump_version.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import warnings +from pathlib import Path + +from .. import _types as _t +from .._log import log as parent_log +from .._version_cls import _version_as_tuple +from ..version import ScmVersion + + +log = parent_log.getChild("dump_version") + +TEMPLATES = { + ".py": """\ +# file generated by setuptools_scm +# don't change, don't track in version control +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Tuple, Union + VERSION_TUPLE = Tuple[Union[int, str], ...] +else: + VERSION_TUPLE = object + +version: str +__version__: str +__version_tuple__: VERSION_TUPLE +version_tuple: VERSION_TUPLE + +__version__ = version = {version!r} +__version_tuple__ = version_tuple = {version_tuple!r} +""", + ".txt": "{version}", +} + + +def dump_version( + root: _t.PathT, + version: str, + write_to: _t.PathT, + template: str | None = None, + scm_version: ScmVersion | None = None, +) -> None: + assert isinstance(version, str) + root = Path(root) + write_to = Path(write_to) + if write_to.is_absolute(): + # trigger warning on escape + write_to.relative_to(root) + warnings.warn( + f"{write_to=!s} is a absolute path," + " please switch to using a relative version file", + DeprecationWarning, + ) + target = write_to + else: + target = Path(root).joinpath(write_to) + write_version_to_path( + target, template=template, version=version, scm_version=scm_version + ) + + +def _validate_template(target: Path, template: str | None) -> str: + if template == "": + warnings.warn(f"{template=} looks like a error, using default instead") + template = None + if template is None: + template = TEMPLATES.get(target.suffix) + + if template is None: + raise ValueError( + f"bad file format: {target.suffix!r} (of {target})\n" + "only *.txt and *.py have a default template" + ) + else: + return template + + +def write_version_to_path( + target: Path, template: str | None, version: str, scm_version: ScmVersion | None +) -> None: + final_template = _validate_template(target, template) + log.debug("dump %s into %s", version, target) + version_tuple = _version_as_tuple(version) + if scm_version is not None: + content = final_template.format( + version=version, + version_tuple=version_tuple, + scm_version=scm_version, + ) + else: + content = final_template.format(version=version, version_tuple=version_tuple) + + target.write_text(content, encoding="utf-8") diff --git a/src/setuptools_scm/_integration/pyproject_reading.py b/src/setuptools_scm/_integration/pyproject_reading.py new file mode 100644 index 0000000..c9818a2 --- /dev/null +++ b/src/setuptools_scm/_integration/pyproject_reading.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import warnings +from pathlib import Path +from typing import NamedTuple + +from .. import _log +from .setuptools import read_dist_name_from_setup_cfg +from .toml import read_toml_content +from .toml import TOML_RESULT + + +log = _log.log.getChild("pyproject_reading") + +_ROOT = "root" + + +class PyProjectData(NamedTuple): + path: Path + tool_name: str + project: TOML_RESULT + section: TOML_RESULT + + @property + def project_name(self) -> str | None: + return self.project.get("name") + + +def read_pyproject( + path: Path = Path("pyproject.toml"), + tool_name: str = "setuptools_scm", + require_section: bool = True, +) -> PyProjectData: + defn = read_toml_content(path, None if require_section else {}) + try: + section = defn.get("tool", {})[tool_name] + except LookupError as e: + error = f"{path} does not contain a tool.{tool_name} section" + if require_section: + raise LookupError(error) from e + else: + log.warning("toml section missing %r", error) + section = {} + + project = defn.get("project", {}) + return PyProjectData(path, tool_name, project, section) + + +def get_args_for_pyproject( + pyproject: PyProjectData, + dist_name: str | None, + kwargs: TOML_RESULT, +) -> TOML_RESULT: + """drops problematic details and figures the distribution name""" + section = pyproject.section.copy() + kwargs = kwargs.copy() + if "relative_to" in section: + relative = section.pop("relative_to") + warnings.warn( + f"{pyproject.path}: at [tool.{pyproject.tool_name}]\n" + f"ignoring value relative_to={relative!r}" + " as its always relative to the config file" + ) + if "dist_name" in section: + if dist_name is None: + dist_name = section.pop("dist_name") + else: + assert dist_name == section["dist_name"] + section.pop("dist_name") + if dist_name is None: + # minimal pep 621 support for figuring the pretend keys + dist_name = pyproject.project_name + if dist_name is None: + dist_name = read_dist_name_from_setup_cfg() + if _ROOT in kwargs: + if kwargs[_ROOT] is None: + kwargs.pop(_ROOT, None) + elif _ROOT in section: + if section[_ROOT] != kwargs[_ROOT]: + warnings.warn( + f"root {section[_ROOT]} is overridden" + f" by the cli arg {kwargs[_ROOT]}" + ) + section.pop(_ROOT, None) + return {"dist_name": dist_name, **section, **kwargs} diff --git a/src/setuptools_scm/_integration/setuptools.py b/src/setuptools_scm/_integration/setuptools.py new file mode 100644 index 0000000..f574d23 --- /dev/null +++ b/src/setuptools_scm/_integration/setuptools.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import logging +import os +import warnings +from typing import Any +from typing import Callable + +import setuptools + +from .. import _config + +log = logging.getLogger(__name__) + + +def read_dist_name_from_setup_cfg( + input: str | os.PathLike[str] = "setup.cfg", +) -> str | None: + # minimal effort to read dist_name off setup.cfg metadata + import configparser + + parser = configparser.ConfigParser() + parser.read([input], encoding="utf-8") + dist_name = parser.get("metadata", "name", fallback=None) + return dist_name + + +def _warn_on_old_setuptools(_version: str = setuptools.__version__) -> None: + if int(_version.split(".")[0]) < 61: + warnings.warn( + RuntimeWarning( + f""" +ERROR: setuptools=={_version} is used in combination with setuptools_scm>=8.x + +Your build configuration is incomplete and previously worked by accident! +setuptools_scm requires setuptools>=61 + +Suggested workaround if applicable: + - migrating from the deprecated setup_requires mechanism to pep517/518 + and using a pyproject.toml to declare build dependencies + which are reliably pre-installed before running the build tools +""" + ) + ) + + +def _assign_version( + dist: setuptools.Distribution, config: _config.Configuration +) -> None: + from .._get_version_impl import _get_version, _version_missing + + # todo: build time plugin + maybe_version = _get_version(config, force_write_version_files=True) + + if maybe_version is None: + _version_missing(config) + else: + assert dist.metadata.version is None + dist.metadata.version = maybe_version + + +_warn_on_old_setuptools() + + +def _log_hookstart(hook: str, dist: setuptools.Distribution) -> None: + log.debug("%s %r", hook, vars(dist.metadata)) + + +def version_keyword( + dist: setuptools.Distribution, + keyword: str, + value: bool | dict[str, Any] | Callable[[], dict[str, Any]], +) -> None: + overrides: dict[str, Any] + if value is True: + overrides = {} + elif callable(value): + overrides = value() + else: + assert isinstance(value, dict), "version_keyword expects a dict or True" + overrides = value + + assert ( + "dist_name" not in overrides + ), "dist_name may not be specified in the setup keyword " + dist_name: str | None = dist.metadata.name + _log_hookstart("version_keyword", dist) + + if dist.metadata.version is not None: + warnings.warn(f"version of {dist_name} already set") + return + + if dist_name is None: + dist_name = read_dist_name_from_setup_cfg() + + config = _config.Configuration.from_file( + dist_name=dist_name, + _require_section=False, + **overrides, + ) + _assign_version(dist, config) + + +def infer_version(dist: setuptools.Distribution) -> None: + _log_hookstart("infer_version", dist) + log.debug("dist %s %s", id(dist), id(dist.metadata)) + if dist.metadata.version is not None: + return # metadata already added by hook + dist_name = dist.metadata.name + if dist_name is None: + dist_name = read_dist_name_from_setup_cfg() + if not os.path.isfile("pyproject.toml"): + return + if dist_name == "setuptools_scm": + return + try: + config = _config.Configuration.from_file(dist_name=dist_name) + except LookupError as e: + log.warning(e) + else: + _assign_version(dist, config) diff --git a/src/setuptools_scm/_integration/toml.py b/src/setuptools_scm/_integration/toml.py new file mode 100644 index 0000000..a08b7b8 --- /dev/null +++ b/src/setuptools_scm/_integration/toml.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any +from typing import Callable +from typing import cast +from typing import Dict +from typing import TYPE_CHECKING +from typing import TypedDict + +if sys.version_info >= (3, 11): + from tomllib import loads as load_toml +else: + from tomli import loads as load_toml + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +from .. import _log + +log = _log.log.getChild("toml") + +TOML_RESULT: TypeAlias = Dict[str, Any] +TOML_LOADER: TypeAlias = Callable[[str], TOML_RESULT] + + +def read_toml_content(path: Path, default: TOML_RESULT | None = None) -> TOML_RESULT: + try: + data = path.read_text(encoding="utf-8") + except FileNotFoundError: + if default is None: + raise + else: + log.debug("%s missing, presuming default %r", path, default) + return default + else: + return load_toml(data) + + +class _CheatTomlData(TypedDict): + cheat: dict[str, Any] + + +def load_toml_or_inline_map(data: str | None) -> dict[str, Any]: + """ + load toml data - with a special hack if only a inline map is given + """ + if not data: + return {} + elif data[0] == "{": + data = "cheat=" + data + loaded: _CheatTomlData = cast(_CheatTomlData, load_toml(data)) + return loaded["cheat"] + return load_toml(data) diff --git a/src/setuptools_scm/_log.py b/src/setuptools_scm/_log.py new file mode 100644 index 0000000..1247d46 --- /dev/null +++ b/src/setuptools_scm/_log.py @@ -0,0 +1,85 @@ +""" +logging helpers, supports vendoring +""" +from __future__ import annotations + +import contextlib +import logging +import os +import sys +from typing import IO +from typing import Iterator +from typing import Mapping + +log = logging.getLogger(__name__.rsplit(".", 1)[0]) +log.propagate = False + + +class AlwaysStdErrHandler(logging.StreamHandler): # type: ignore[type-arg] + def __init___(self) -> None: + super().__init__(sys.stderr) + + @property # type: ignore [override] + def stream(self) -> IO[str]: + return sys.stderr + + @stream.setter + def stream(self, value: IO[str]) -> None: + assert value is sys.stderr + + +def make_default_handler() -> logging.Handler: + try: + from rich.console import Console + + console = Console(stderr=True) + from rich.logging import RichHandler + + return RichHandler(console=console) + except ImportError: + handler = AlwaysStdErrHandler() + handler.setFormatter(logging.Formatter("%(levelname)s %(name)s %(message)s")) + return handler + + +_default_handler = make_default_handler() + +log.addHandler(_default_handler) + + +def _default_log_level(_env: Mapping[str, str] = os.environ) -> int: + val: str | None = _env.get("SETUPTOOLS_SCM_DEBUG") + return logging.WARN if val is None else logging.DEBUG + + +log.setLevel(_default_log_level()) + + +@contextlib.contextmanager +def defer_to_pytest() -> Iterator[None]: + log.propagate = True + old_level = log.level + log.setLevel(logging.NOTSET) + log.removeHandler(_default_handler) + try: + yield + finally: + log.addHandler(_default_handler) + log.propagate = False + log.setLevel(old_level) + + +@contextlib.contextmanager +def enable_debug(handler: logging.Handler = _default_handler) -> Iterator[None]: + log.addHandler(handler) + old_level = log.level + log.setLevel(logging.DEBUG) + old_handler_level = handler.level + handler.setLevel(logging.DEBUG) + try: + yield + finally: + log.setLevel(old_level) + handler.setLevel(old_handler_level) + if handler is not _default_handler: + log.removeHandler(handler) diff --git a/src/setuptools_scm/_modify_version.py b/src/setuptools_scm/_modify_version.py new file mode 100644 index 0000000..63c0dfd --- /dev/null +++ b/src/setuptools_scm/_modify_version.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import re + +from . import _types as _t + + +def strip_local(version_string: str) -> str: + public, sep, local = version_string.partition("+") + return public + + +def _add_post(version: str) -> str: + if "post" in version: + raise ValueError( + f"{version} already is a post release, refusing to guess the update" + ) + return f"{version}.post1" + + +def _bump_dev(version: str) -> str | None: + if ".dev" not in version: + return None + + prefix, tail = version.rsplit(".dev", 1) + if tail != "0": + raise ValueError( + "choosing custom numbers for the `.devX` distance " + "is not supported.\n " + f"The {version} can't be bumped\n" + "Please drop the tag or create a new supported one ending in .dev0" + ) + return prefix + + +def _bump_regex(version: str) -> str: + match = re.match(r"(.*?)(\d+)$", version) + if match is None: + raise ValueError( + f"{version} does not end with a number to bump, " + "please correct or use a custom version scheme" + ) + else: + prefix, tail = match.groups() + return f"{prefix}{int(tail) + 1}" + + +def _format_local_with_time(version: _t.SCMVERSION, time_format: str) -> str: + if version.exact or version.node is None: + return version.format_choice( + "", "+d{time:{time_format}}", time_format=time_format + ) + else: + return version.format_choice( + "+{node}", "+{node}.d{time:{time_format}}", time_format=time_format + ) + + +def _dont_guess_next_version(tag_version: _t.SCMVERSION) -> str: + version = strip_local(str(tag_version.tag)) + return _bump_dev(version) or _add_post(version) diff --git a/src/setuptools_scm/_overrides.py b/src/setuptools_scm/_overrides.py new file mode 100644 index 0000000..792bfd2 --- /dev/null +++ b/src/setuptools_scm/_overrides.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +import re +from typing import Any + +from . import _config +from . import _log +from . import version +from ._integration.toml import load_toml_or_inline_map + +log = _log.log.getChild("overrides") + +PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION" +PRETEND_KEY_NAMED = PRETEND_KEY + "_FOR_{name}" + + +def read_named_env( + *, tool: str = "SETUPTOOLS_SCM", name: str, dist_name: str | None +) -> str | None: + """ """ + if dist_name is not None: + # Normalize the dist name as per PEP 503. + normalized_dist_name = re.sub(r"[-_.]+", "-", dist_name) + env_var_dist_name = normalized_dist_name.replace("-", "_").upper() + val = os.environ.get(f"{tool}_{name}_FOR_{env_var_dist_name}") + if val is not None: + return val + return os.environ.get(f"{tool}_{name}") + + +def _read_pretended_version_for( + config: _config.Configuration, +) -> version.ScmVersion | None: + """read a a overridden version from the environment + + tries ``SETUPTOOLS_SCM_PRETEND_VERSION`` + and ``SETUPTOOLS_SCM_PRETEND_VERSION_FOR_$UPPERCASE_DIST_NAME`` + """ + log.debug("dist name: %s", config.dist_name) + + pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name) + + if pretended: + # we use meta here since the pretended version + # must adhere to the pep to begin with + return version.meta(tag=pretended, preformatted=True, config=config) + else: + return None + + +def read_toml_overrides(dist_name: str | None) -> dict[str, Any]: + data = read_named_env(name="OVERRIDES", dist_name=dist_name) + return load_toml_or_inline_map(data) diff --git a/src/setuptools_scm/_run_cmd.py b/src/setuptools_scm/_run_cmd.py new file mode 100644 index 0000000..1b80d28 --- /dev/null +++ b/src/setuptools_scm/_run_cmd.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +import textwrap +import warnings +from typing import Callable +from typing import Final +from typing import Mapping +from typing import overload +from typing import Sequence +from typing import TYPE_CHECKING +from typing import TypeVar + +from . import _log +from . import _types as _t + +if TYPE_CHECKING: + BaseCompletedProcess = subprocess.CompletedProcess[str] +else: + BaseCompletedProcess = subprocess.CompletedProcess + +# pick 40 seconds +# unfortunately github CI for windows sometimes needs +# up to 30 seconds to start a command + +BROKEN_TIMEOUT: Final[int] = 40 + +log = _log.log.getChild("run_cmd") + +PARSE_RESULT = TypeVar("PARSE_RESULT") +T = TypeVar("T") + + +class CompletedProcess(BaseCompletedProcess): + @classmethod + def from_raw( + cls, input: BaseCompletedProcess, strip: bool = True + ) -> CompletedProcess: + return cls( + args=input.args, + returncode=input.returncode, + stdout=input.stdout.strip() if strip and input.stdout else input.stdout, + stderr=input.stderr.strip() if strip and input.stderr else input.stderr, + ) + + @overload + def parse_success( + self, + parse: Callable[[str], PARSE_RESULT], + default: None = None, + error_msg: str | None = None, + ) -> PARSE_RESULT | None: + ... + + @overload + def parse_success( + self, + parse: Callable[[str], PARSE_RESULT], + default: T, + error_msg: str | None = None, + ) -> PARSE_RESULT | T: + ... + + def parse_success( + self, + parse: Callable[[str], PARSE_RESULT], + default: T | None = None, + error_msg: str | None = None, + ) -> PARSE_RESULT | T | None: + if self.returncode: + if error_msg: + log.warning("%s %s", error_msg, self) + return default + else: + return parse(self.stdout) + + +def no_git_env(env: Mapping[str, str]) -> dict[str, str]: + # adapted from pre-commit + # Too many bugs dealing with environment variables and GIT: + # https://github.com/pre-commit/pre-commit/issues/300 + # In git 2.6.3 (maybe others), git exports GIT_WORK_TREE while running + # pre-commit hooks + # In git 1.9.1 (maybe others), git exports GIT_DIR and GIT_INDEX_FILE + # while running pre-commit hooks in submodules. + # GIT_DIR: Causes git clone to clone wrong thing + # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit + for k, v in env.items(): + if k.startswith("GIT_"): + log.debug("%s: %s", k, v) + return { + k: v + for k, v in env.items() + if not k.startswith("GIT_") + or k in ("GIT_EXEC_PATH", "GIT_SSH", "GIT_SSH_COMMAND") + } + + +def avoid_pip_isolation(env: Mapping[str, str]) -> dict[str, str]: + """ + pip build isolation can break Mercurial + (see https://github.com/pypa/pip/issues/10635) + + pip uses PYTHONNOUSERSITE and a path in PYTHONPATH containing "pip-build-env-". + """ + new_env = {k: v for k, v in env.items() if k != "PYTHONNOUSERSITE"} + if "PYTHONPATH" not in new_env: + return new_env + + new_env["PYTHONPATH"] = os.pathsep.join( + [ + path + for path in new_env["PYTHONPATH"].split(os.pathsep) + if "pip-build-env-" not in path + ] + ) + return new_env + + +def ensure_stripped_str(str_or_bytes: str | bytes) -> str: + if isinstance(str_or_bytes, str): + return str_or_bytes.strip() + else: + return str_or_bytes.decode("utf-8", "surrogateescape").strip() + + +def run( + cmd: _t.CMD_TYPE, + cwd: _t.PathT, + *, + strip: bool = True, + trace: bool = True, + timeout: int = BROKEN_TIMEOUT, + check: bool = False, +) -> CompletedProcess: + if isinstance(cmd, str): + cmd = shlex.split(cmd) + else: + cmd = [os.fspath(x) for x in cmd] + cmd_4_trace = " ".join(map(_unsafe_quote_for_display, cmd)) + log.debug("at %s\n $ %s ", cwd, cmd_4_trace) + res = subprocess.run( + cmd, + capture_output=True, + cwd=os.fspath(cwd), + env=dict( + avoid_pip_isolation(no_git_env(os.environ)), + # os.environ, + # try to disable i18n, but still allow UTF-8 encoded text. + LC_ALL="C.UTF-8", + LANGUAGE="", + HGPLAIN="1", + ), + text=True, + timeout=timeout, + ) + + res = CompletedProcess.from_raw(res, strip=strip) + if trace: + if res.stdout: + log.debug("out:\n%s", textwrap.indent(res.stdout, " ")) + if res.stderr: + log.debug("err:\n%s", textwrap.indent(res.stderr, " ")) + if res.returncode: + log.debug("ret: %s", res.returncode) + if check: + res.check_returncode() + return res + + +def _unsafe_quote_for_display(item: _t.PathT) -> str: + # give better results than shlex.join in our cases + text = os.fspath(item) + return text if all(c not in text for c in " {[:") else f'"{text}"' + + +def has_command( + name: str, args: Sequence[str] = ["version"], warn: bool = True +) -> bool: + try: + p = run([name, *args], cwd=".", timeout=BROKEN_TIMEOUT) + if p.returncode != 0: + log.error(f"Command '{name}' returned non-zero. This is stderr:") + log.error(p.stderr) + except OSError as e: + log.warning("command %s missing: %s", name, e) + res = False + except subprocess.TimeoutExpired as e: + log.warning("command %s timed out %s", name, e) + res = False + + else: + res = not p.returncode + if not res and warn: + warnings.warn("%r was not found" % name, category=RuntimeWarning) + return res + + +class CommandNotFoundError(LookupError, FileNotFoundError): + pass + + +def require_command(name: str) -> None: + if not has_command(name, warn=False): + raise CommandNotFoundError(name) diff --git a/src/setuptools_scm/_types.py b/src/setuptools_scm/_types.py new file mode 100644 index 0000000..df8fa94 --- /dev/null +++ b/src/setuptools_scm/_types.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +from typing import Callable +from typing import List +from typing import Sequence +from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union + + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + from . import version + +PathT: TypeAlias = Union["os.PathLike[str]", str] + +CMD_TYPE: TypeAlias = Union[Sequence[PathT], str] + +VERSION_SCHEME: TypeAlias = Union[str, Callable[["version.ScmVersion"], str]] +VERSION_SCHEMES: TypeAlias = Union[List[str], Tuple[str, ...], VERSION_SCHEME] +SCMVERSION: TypeAlias = "version.ScmVersion" diff --git a/src/setuptools_scm/_version_cls.py b/src/setuptools_scm/_version_cls.py new file mode 100644 index 0000000..3fd4a32 --- /dev/null +++ b/src/setuptools_scm/_version_cls.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import cast +from typing import Type +from typing import Union + +try: + from packaging.version import InvalidVersion + from packaging.version import Version as Version +except ImportError: + from setuptools.extern.packaging.version import InvalidVersion # type: ignore + from setuptools.extern.packaging.version import Version as Version # type: ignore +from . import _log + +log = _log.log.getChild("version_cls") + + +class NonNormalizedVersion(Version): + """A non-normalizing version handler. + + You can use this class to preserve version verification but skip normalization. + For example you can use this to avoid git release candidate version tags + ("1.0.0-rc1") to be normalized to "1.0.0rc1". Only use this if you fully + trust the version tags. + """ + + def __init__(self, version: str) -> None: + # parse and validate using parent + super().__init__(version) + + # store raw for str + self._raw_version = version + + def __str__(self) -> str: + # return the non-normalized version (parent returns the normalized) + return self._raw_version + + def __repr__(self) -> str: + # same pattern as parent + return f"" + + +def _version_as_tuple(version_str: str) -> tuple[int | str, ...]: + try: + parsed_version = Version(version_str) + except InvalidVersion as e: + log.error("failed to parse version %s: %s", e, version_str) + return (version_str,) + else: + version_fields: tuple[int | str, ...] = parsed_version.release + if parsed_version.dev is not None: + version_fields += (f"dev{parsed_version.dev}",) + if parsed_version.local is not None: + version_fields += (parsed_version.local,) + return version_fields + + +_VersionT = Union[Version, NonNormalizedVersion] + + +def import_name(name: str) -> object: + import importlib + + pkg_name, cls_name = name.rsplit(".", 1) + pkg = importlib.import_module(pkg_name) + return getattr(pkg, cls_name) + + +def _validate_version_cls( + version_cls: type[_VersionT] | str | None, normalize: bool +) -> type[_VersionT]: + if not normalize: + if version_cls is not None: + raise ValueError( + "Providing a custom `version_cls` is not permitted when " + "`normalize=False`" + ) + return NonNormalizedVersion + else: + # Use `version_cls` if provided, default to packaging or pkg_resources + if version_cls is None: + return Version + elif isinstance(version_cls, str): + try: + return cast(Type[_VersionT], import_name(version_cls)) + except: # noqa + raise ValueError( + f"Unable to import version_cls='{version_cls}'" + ) from None + else: + return version_cls diff --git a/src/setuptools_scm/config.py b/src/setuptools_scm/config.py deleted file mode 100644 index e7f4d72..0000000 --- a/src/setuptools_scm/config.py +++ /dev/null @@ -1,125 +0,0 @@ -""" configuration """ -from __future__ import print_function, unicode_literals -import os -import re -import warnings - -from .utils import trace - -DEFAULT_TAG_REGEX = r"^(?:[\w-]+-)?(?P[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$" -DEFAULT_VERSION_SCHEME = "guess-next-dev" -DEFAULT_LOCAL_SCHEME = "node-and-date" - - -def _check_tag_regex(value): - if not value: - value = DEFAULT_TAG_REGEX - regex = re.compile(value) - - group_names = regex.groupindex.keys() - if regex.groups == 0 or (regex.groups > 1 and "version" not in group_names): - warnings.warn( - "Expected tag_regex to contain a single match group or a group named" - " 'version' to identify the version part of any tag." - ) - - return regex - - -def _check_absolute_root(root, relative_to): - if relative_to: - if os.path.isabs(root) and not root.startswith(relative_to): - warnings.warn( - "absolute root path '%s' overrides relative_to '%s'" - % (root, relative_to) - ) - root = os.path.join(os.path.dirname(relative_to), root) - return os.path.abspath(root) - - -class Configuration(object): - """ Global configuration model """ - - def __init__( - self, - relative_to=None, - root=".", - version_scheme=DEFAULT_VERSION_SCHEME, - local_scheme=DEFAULT_LOCAL_SCHEME, - write_to=None, - write_to_template=None, - tag_regex=DEFAULT_TAG_REGEX, - parentdir_prefix_version=None, - fallback_version=None, - fallback_root=".", - parse=None, - git_describe_command=None, - ): - # TODO: - self._relative_to = relative_to - self._root = "." - - self.root = root - self.version_scheme = version_scheme - self.local_scheme = local_scheme - self.write_to = write_to - self.write_to_template = write_to_template - self.parentdir_prefix_version = parentdir_prefix_version - self.fallback_version = fallback_version - self.fallback_root = fallback_root - self.parse = parse - self.tag_regex = tag_regex - self.git_describe_command = git_describe_command - - @property - def fallback_root(self): - return self._fallback_root - - @fallback_root.setter - def fallback_root(self, value): - self._fallback_root = os.path.abspath(value) - - @property - def absolute_root(self): - return self._absolute_root - - @property - def relative_to(self): - return self._relative_to - - @relative_to.setter - def relative_to(self, value): - self._absolute_root = _check_absolute_root(self._root, value) - self._relative_to = value - trace("root", repr(self._absolute_root)) - - @property - def root(self): - return self._root - - @root.setter - def root(self, value): - self._absolute_root = _check_absolute_root(value, self._relative_to) - self._root = value - trace("root", repr(self._absolute_root)) - - @property - def tag_regex(self): - return self._tag_regex - - @tag_regex.setter - def tag_regex(self, value): - self._tag_regex = _check_tag_regex(value) - - @classmethod - def from_file(cls, name="pyproject.toml"): - """ - Read Configuration from pyproject.toml (or similar). - Raises exceptions when file is not found or toml is - not installed or the file has invalid format or does - not contain the [tool.setuptools_scm] section. - """ - with open(name) as strm: - defn = __import__("toml").load(strm) - section = defn.get("tool", {})["setuptools_scm"] - return cls(**section) diff --git a/src/setuptools_scm/discover.py b/src/setuptools_scm/discover.py index 019f1c5..b12b2f1 100644 --- a/src/setuptools_scm/discover.py +++ b/src/setuptools_scm/discover.py @@ -1,13 +1,69 @@ +from __future__ import annotations + import os -from pkg_resources import iter_entry_points -from .utils import trace - - -def iter_matching_entrypoints(path, entrypoint): - trace("looking for ep", entrypoint, path) - for ep in iter_entry_points(entrypoint): - if os.path.exists(os.path.join(path, ep.name)): - if os.path.isabs(ep.name): - trace("ignoring bad ep", ep) - trace("found ep", ep) - yield ep +from pathlib import Path +from typing import Iterable +from typing import Iterator + +from . import _entrypoints +from . import _log +from . import _types as _t +from ._config import Configuration + +log = _log.log.getChild("discover") + + +def walk_potential_roots(root: _t.PathT, search_parents: bool = True) -> Iterator[Path]: + """ + Iterate though a path and each of its parents. + :param root: File path. + :param search_parents: If ``False`` the parents are not considered. + """ + root = Path(root) + yield root + if search_parents: + yield from root.parents + + +def match_entrypoint(root: _t.PathT, name: str) -> bool: + """ + Consider a ``root`` as entry-point. + :param root: File path. + :param name: Subdirectory name. + :return: ``True`` if a subdirectory ``name`` exits in ``root``. + """ + + if os.path.exists(os.path.join(root, name)): + if not os.path.isabs(name): + return True + log.debug("ignoring bad ep %s", name) + + return False + + +# blocked entrypints from legacy plugins +_BLOCKED_EP_TARGETS = {"setuptools_scm_git_archive:parse"} + + +def iter_matching_entrypoints( + root: _t.PathT, entrypoint: str, config: Configuration +) -> Iterable[_entrypoints.EntryPoint]: + """ + Consider different entry-points in ``root`` and optionally its parents. + :param root: File path. + :param entrypoint: Entry-point to consider. + :param config: Configuration, + read ``search_parent_directories``, write found parent to ``parent``. + """ + + log.debug("looking for ep %s in %s", entrypoint, root) + from ._entrypoints import iter_entry_points + + for wd in walk_potential_roots(root, config.search_parent_directories): + for ep in iter_entry_points(entrypoint): + if ep.value in _BLOCKED_EP_TARGETS: + continue + if match_entrypoint(wd, ep.name): + log.debug("found ep %s in %s", ep, wd) + config.parent = wd + yield ep diff --git a/src/setuptools_scm/fallbacks.py b/src/setuptools_scm/fallbacks.py new file mode 100644 index 0000000..e1ea60c --- /dev/null +++ b/src/setuptools_scm/fallbacks.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import _types as _t +from . import Configuration +from .integration import data_from_mime +from .version import meta +from .version import ScmVersion +from .version import tag_to_version + +log = logging.getLogger(__name__) + +_UNKNOWN = "UNKNOWN" + + +def parse_pkginfo(root: _t.PathT, config: Configuration) -> ScmVersion | None: + pkginfo = Path(root) / "PKG-INFO" + log.debug("pkginfo %s", pkginfo) + data = data_from_mime(pkginfo) + version = data.get("Version", _UNKNOWN) + if version != _UNKNOWN: + return meta(version, preformatted=True, config=config) + else: + return None + + +def fallback_version(root: _t.PathT, config: Configuration) -> ScmVersion | None: + if config.parentdir_prefix_version is not None: + _, parent_name = os.path.split(os.path.abspath(root)) + if parent_name.startswith(config.parentdir_prefix_version): + version = tag_to_version( + parent_name[len(config.parentdir_prefix_version) :], config + ) + if version is not None: + return meta(str(version), preformatted=True, config=config) + if config.fallback_version is not None: + log.debug("FALLBACK %s", config.fallback_version) + return meta(config.fallback_version, preformatted=True, config=config) + return None diff --git a/src/setuptools_scm/file_finder.py b/src/setuptools_scm/file_finder.py deleted file mode 100644 index 77ec146..0000000 --- a/src/setuptools_scm/file_finder.py +++ /dev/null @@ -1,55 +0,0 @@ -import os - - -def scm_find_files(path, scm_files, scm_dirs): - """ setuptools compatible file finder that follows symlinks - - - path: the root directory from which to search - - scm_files: set of scm controlled files and symlinks - (including symlinks to directories) - - scm_dirs: set of scm controlled directories - (including directories containing no scm controlled files) - - scm_files and scm_dirs must be absolute with symlinks resolved (realpath), - with normalized case (normcase) - - Spec here: http://setuptools.readthedocs.io/en/latest/setuptools.html#\ - adding-support-for-revision-control-systems - """ - realpath = os.path.normcase(os.path.realpath(path)) - seen = set() - res = [] - for dirpath, dirnames, filenames in os.walk(realpath, followlinks=True): - # dirpath with symlinks resolved - realdirpath = os.path.normcase(os.path.realpath(dirpath)) - - def _link_not_in_scm(n): - fn = os.path.join(realdirpath, os.path.normcase(n)) - return os.path.islink(fn) and fn not in scm_files - - if realdirpath not in scm_dirs: - # directory not in scm, don't walk it's content - dirnames[:] = [] - continue - if os.path.islink(dirpath) and not os.path.relpath( - realdirpath, realpath - ).startswith(os.pardir): - # a symlink to a directory not outside path: - # we keep it in the result and don't walk its content - res.append(os.path.join(path, os.path.relpath(dirpath, path))) - dirnames[:] = [] - continue - if realdirpath in seen: - # symlink loop protection - dirnames[:] = [] - continue - dirnames[:] = [dn for dn in dirnames if not _link_not_in_scm(dn)] - for filename in filenames: - if _link_not_in_scm(filename): - continue - # dirpath + filename with symlinks preserved - fullfilename = os.path.join(dirpath, filename) - if os.path.normcase(os.path.realpath(fullfilename)) in scm_files: - res.append(os.path.join(path, os.path.relpath(fullfilename, realpath))) - seen.add(realdirpath) - return res diff --git a/src/setuptools_scm/file_finder_git.py b/src/setuptools_scm/file_finder_git.py deleted file mode 100644 index 9aa6245..0000000 --- a/src/setuptools_scm/file_finder_git.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -import subprocess -import tarfile -import logging -from .file_finder import scm_find_files -from .utils import trace - -log = logging.getLogger(__name__) - - -def _git_toplevel(path): - try: - with open(os.devnull, "wb") as devnull: - out = subprocess.check_output( - ["git", "rev-parse", "--show-toplevel"], - cwd=(path or "."), - universal_newlines=True, - stderr=devnull, - ) - trace("find files toplevel", out) - return os.path.normcase(os.path.realpath(out.strip())) - except subprocess.CalledProcessError: - # git returned error, we are not in a git repo - return None - except OSError: - # git command not found, probably - return None - - -def _git_interpret_archive(fd, toplevel): - with tarfile.open(fileobj=fd, mode="r|*") as tf: - git_files = set() - git_dirs = {toplevel} - for member in tf.getmembers(): - name = os.path.normcase(member.name).replace("/", os.path.sep) - if member.type == tarfile.DIRTYPE: - git_dirs.add(name) - else: - git_files.add(name) - return git_files, git_dirs - - -def _git_ls_files_and_dirs(toplevel): - # use git archive instead of git ls-file to honor - # export-ignore git attribute - cmd = ["git", "archive", "--prefix", toplevel + os.path.sep, "HEAD"] - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, cwd=toplevel) - try: - try: - return _git_interpret_archive(proc.stdout, toplevel) - finally: - # ensure we avoid resource warnings by cleaning up the process - proc.stdout.close() - proc.terminate() - except Exception: - if proc.wait() != 0: - log.exception("listing git files failed - pretending there aren't any") - return (), () - - -def git_find_files(path=""): - toplevel = _git_toplevel(path) - if not toplevel: - return [] - fullpath = os.path.abspath(os.path.normpath(path)) - if not fullpath.startswith(toplevel): - trace("toplevel mismatch", toplevel, fullpath) - git_files, git_dirs = _git_ls_files_and_dirs(toplevel) - return scm_find_files(path, git_files, git_dirs) diff --git a/src/setuptools_scm/file_finder_hg.py b/src/setuptools_scm/file_finder_hg.py deleted file mode 100644 index 2aa1e16..0000000 --- a/src/setuptools_scm/file_finder_hg.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import subprocess - -from .file_finder import scm_find_files - - -def _hg_toplevel(path): - try: - with open(os.devnull, "wb") as devnull: - out = subprocess.check_output( - ["hg", "root"], - cwd=(path or "."), - universal_newlines=True, - stderr=devnull, - ) - return os.path.normcase(os.path.realpath(out.strip())) - except subprocess.CalledProcessError: - # hg returned error, we are not in a mercurial repo - return None - except OSError: - # hg command not found, probably - return None - - -def _hg_ls_files_and_dirs(toplevel): - hg_files = set() - hg_dirs = {toplevel} - out = subprocess.check_output( - ["hg", "files"], cwd=toplevel, universal_newlines=True - ) - for name in out.splitlines(): - name = os.path.normcase(name).replace("/", os.path.sep) - fullname = os.path.join(toplevel, name) - hg_files.add(fullname) - dirname = os.path.dirname(fullname) - while len(dirname) > len(toplevel) and dirname not in hg_dirs: - hg_dirs.add(dirname) - dirname = os.path.dirname(dirname) - return hg_files, hg_dirs - - -def hg_find_files(path=""): - toplevel = _hg_toplevel(path) - if not toplevel: - return [] - hg_files, hg_dirs = _hg_ls_files_and_dirs(toplevel) - return scm_find_files(path, hg_files, hg_dirs) diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py index afefa34..d511961 100644 --- a/src/setuptools_scm/git.py +++ b/src/setuptools_scm/git.py @@ -1,146 +1,285 @@ -from .config import Configuration -from .utils import do_ex, trace, has_command -from .version import meta +from __future__ import annotations -from os.path import isfile, join +import dataclasses +import logging +import os +import re +import shlex import warnings +from datetime import date +from datetime import datetime +from datetime import timezone +from os.path import samefile +from pathlib import Path +from typing import Callable +from typing import Sequence +from typing import TYPE_CHECKING + +from . import _types as _t +from . import Configuration +from . import discover +from ._run_cmd import CompletedProcess as _CompletedProcess +from ._run_cmd import require_command as _require_command +from ._run_cmd import run as _run +from .integration import data_from_mime +from .scm_workdir import Workdir +from .version import meta +from .version import ScmVersion +from .version import tag_to_version + +if TYPE_CHECKING: + from . import hg_git +log = logging.getLogger(__name__) + +REF_TAG_RE = re.compile(r"(?<=\btag: )([^,]+)\b") +DESCRIBE_UNSUPPORTED = "%(describe" + +# If testing command in shell make sure to quote the match argument like +# '*[0-9]*' as it will expand before being sent to git if there are any matching +# files in current directory. +DEFAULT_DESCRIBE = [ + "git", + "describe", + "--dirty", + "--tags", + "--long", + "--match", + "*[0-9]*", +] + + +def run_git( + args: Sequence[str | os.PathLike[str]], + repo: Path, + *, + check: bool = False, + timeout: int = 20, +) -> _CompletedProcess: + return _run( + ["git", "--git-dir", repo / ".git", *args], + cwd=repo, + check=check, + timeout=timeout, + ) + + +class GitWorkdir(Workdir): + """experimental, may change at any time""" + @classmethod + def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdir | None: + wd = Path(wd).resolve() + real_wd = run_git(["rev-parse", "--show-prefix"], wd).parse_success(parse=str) + if real_wd is None: + return None + else: + real_wd = real_wd[:-1] # remove the trailing pathsep -try: - from os.path import samefile -except ImportError: - from .win_py31_compat import samefile - - -DEFAULT_DESCRIBE = "git describe --dirty --tags --long --match *.*" - - -class GitWorkdir(object): - """experimental, may change at any time""" + if not real_wd: + real_wd = os.fspath(wd) + else: + str_wd = os.fspath(wd) + assert str_wd.replace("\\", "/").endswith(real_wd) + # In windows wd contains ``\`` which should be replaced by ``/`` + # for this assertion to work. Length of string isn't changed by replace + # ``\\`` is just and escape for `\` + real_wd = str_wd[: -len(real_wd)] + log.debug("real root %s", real_wd) + if not samefile(real_wd, wd): + return None - def __init__(self, path): - self.path = path + return cls(Path(real_wd)) - def do_ex(self, cmd): - return do_ex(cmd, cwd=self.path) + def is_dirty(self) -> bool: + return run_git( + ["status", "--porcelain", "--untracked-files=no"], self.path + ).parse_success( + parse=bool, + default=False, + ) - @classmethod - def from_potential_worktree(cls, wd): - real_wd, _, ret = do_ex("git rev-parse --show-toplevel", wd) - if ret: - return - trace("real root", real_wd) - if not samefile(real_wd, wd): - return + def get_branch(self) -> str | None: + return run_git( + ["rev-parse", "--abbrev-ref", "HEAD"], + self.path, + ).parse_success( + parse=str, + error_msg="branch err (abbrev-err)", + ) or run_git( + ["symbolic-ref", "--short", "HEAD"], + self.path, + ).parse_success( + parse=str, + error_msg="branch err (symbolic-ref)", + ) - return cls(real_wd) + def get_head_date(self) -> date | None: + def parse_timestamp(timestamp_text: str) -> date | None: + if "%c" in timestamp_text: + log.warning("git too old -> timestamp is %r", timestamp_text) + return None + return datetime.fromisoformat(timestamp_text).date() + + res = run_git( + [ + *("-c", "log.showSignature=false"), + *("log", "-n", "1", "HEAD"), + "--format=%cI", + ], + self.path, + ) + return res.parse_success( + parse=parse_timestamp, + error_msg="logging the iso date for head failed", + ) - def is_dirty(self): - out, _, _ = self.do_ex("git status --porcelain --untracked-files=no") - return bool(out) + def is_shallow(self) -> bool: + return self.path.joinpath(".git/shallow").is_file() - def get_branch(self): - branch, err, ret = self.do_ex("git rev-parse --abbrev-ref HEAD") - if ret: - trace("branch err", branch, err, ret) - return - return branch + def fetch_shallow(self) -> None: + run_git(["fetch", "--unshallow"], self.path, check=True, timeout=240) - def is_shallow(self): - return isfile(join(self.path, ".git/shallow")) + def node(self) -> str | None: + def _unsafe_short_node(node: str) -> str: + return node[:7] - def fetch_shallow(self): - self.do_ex("git fetch --unshallow") + return run_git( + ["rev-parse", "--verify", "--quiet", "HEAD"], self.path + ).parse_success( + parse=_unsafe_short_node, + ) - def node(self): - rev_node, _, ret = self.do_ex("git rev-parse --verify --quiet HEAD") - if not ret: - return rev_node[:7] + def count_all_nodes(self) -> int: + res = run_git(["rev-list", "HEAD"], self.path) + return res.stdout.count("\n") + 1 - def count_all_nodes(self): - revs, _, _ = self.do_ex("git rev-list HEAD") - return revs.count("\n") + 1 + def default_describe(self) -> _CompletedProcess: + return run_git(DEFAULT_DESCRIBE[1:], self.path) -def warn_on_shallow(wd): +def warn_on_shallow(wd: GitWorkdir) -> None: """experimental, may change at any time""" if wd.is_shallow(): - warnings.warn('"{}" is shallow and may cause errors'.format(wd.path)) + warnings.warn(f'"{wd.path}" is shallow and may cause errors') -def fetch_on_shallow(wd): +def fetch_on_shallow(wd: GitWorkdir) -> None: """experimental, may change at any time""" if wd.is_shallow(): - warnings.warn('"%s" was shallow, git fetch was used to rectify') + warnings.warn(f'"{wd.path}" was shallow, git fetch was used to rectify') wd.fetch_shallow() -def fail_on_shallow(wd): +def fail_on_shallow(wd: GitWorkdir) -> None: """experimental, may change at any time""" if wd.is_shallow(): raise ValueError( - "%r is shallow, please correct with " '"git fetch --unshallow"' % wd.path + f'{wd.path} is shallow, please correct with "git fetch --unshallow"' ) +def get_working_directory(config: Configuration, root: _t.PathT) -> GitWorkdir | None: + """ + Return the working directory (``GitWorkdir``). + """ + + if config.parent: # todo broken + return GitWorkdir.from_potential_worktree(config.parent) + + for potential_root in discover.walk_potential_roots( + root, search_parents=config.search_parent_directories + ): + potential_wd = GitWorkdir.from_potential_worktree(potential_root) + if potential_wd is not None: + return potential_wd + + return GitWorkdir.from_potential_worktree(root) + + def parse( - root, describe_command=DEFAULT_DESCRIBE, pre_parse=warn_on_shallow, config=None -): + root: _t.PathT, + config: Configuration, + describe_command: str | list[str] | None = None, + pre_parse: Callable[[GitWorkdir], None] = warn_on_shallow, +) -> ScmVersion | None: """ :param pre_parse: experimental pre_parse action, may change at any time """ - if not config: - config = Configuration(root=root) + _require_command("git") + wd = get_working_directory(config, root) + if wd: + return _git_parse_inner( + config, wd, describe_command=describe_command, pre_parse=pre_parse + ) + else: + return None - if not has_command("git"): - return - wd = GitWorkdir.from_potential_worktree(config.absolute_root) - if wd is None: - return - if pre_parse: - pre_parse(wd) +def version_from_describe( + wd: GitWorkdir | hg_git.GitWorkdirHgClient, + config: Configuration, + describe_command: _t.CMD_TYPE | None, +) -> ScmVersion | None: + pass - if config.git_describe_command: + if config.git_describe_command is not None: describe_command = config.git_describe_command - out, unused_err, ret = wd.do_ex(describe_command) - if ret: - # If 'git git_describe_command' failed, try to get the information otherwise. - rev_node = wd.node() - dirty = wd.is_dirty() + if describe_command is not None: + if isinstance(describe_command, str): + describe_command = shlex.split(describe_command) + # todo: figure how to ensure git with gitdir gets correctly invoked + if describe_command[0] == "git": + describe_res = run_git(describe_command[1:], wd.path) + else: + describe_res = _run(describe_command, wd.path) + else: + describe_res = wd.default_describe() - if rev_node is None: - return meta("0.0", distance=0, dirty=dirty, config=config) + def parse_describe(output: str) -> ScmVersion: + tag, distance, node, dirty = _git_parse_describe(output) + return meta(tag=tag, distance=distance, dirty=dirty, node=node, config=config) - return meta( - "0.0", - distance=wd.count_all_nodes(), - node="g" + rev_node, - dirty=dirty, - branch=wd.get_branch(), - config=config, - ) - else: - tag, number, node, dirty = _git_parse_describe(out) - - branch = wd.get_branch() - if number: - return meta( - tag, - config=config, - distance=number, - node=node, - dirty=dirty, - branch=branch, - ) + return describe_res.parse_success(parse=parse_describe) + + +def _git_parse_inner( + config: Configuration, + wd: GitWorkdir | hg_git.GitWorkdirHgClient, + pre_parse: None | (Callable[[GitWorkdir | hg_git.GitWorkdirHgClient], None]) = None, + describe_command: _t.CMD_TYPE | None = None, +) -> ScmVersion: + if pre_parse: + pre_parse(wd) + + version = version_from_describe(wd, config, describe_command) + + if version is None: + # If 'git git_describe_command' failed, try to get the information otherwise. + tag = config.version_cls("0.0") + node = wd.node() + if node is None: + distance = 0 + dirty = True else: - return meta(tag, config=config, node=node, dirty=dirty, branch=branch) + distance = wd.count_all_nodes() + node = "g" + node + dirty = wd.is_dirty() + version = meta( + tag=tag, distance=distance, dirty=dirty, node=node, config=config + ) + branch = wd.get_branch() + node_date = wd.get_head_date() or datetime.now(timezone.utc).date() + return dataclasses.replace(version, branch=branch, node_date=node_date) -def _git_parse_describe(describe_output): +def _git_parse_describe( + describe_output: str, +) -> tuple[str, int, str | None, bool]: # 'describe_output' looks e.g. like 'v1.5.0-0-g4060507' or # 'v1.15.1rc1-37-g9bd1298-dirty'. + # It may also just be a bare tag name if this is a tagged commit and we are + # parsing a .git_archival.txt file. if describe_output.endswith("-dirty"): dirty = True @@ -148,6 +287,50 @@ def _git_parse_describe(describe_output): else: dirty = False - tag, number, node = describe_output.rsplit("-", 2) - number = int(number) + split = describe_output.rsplit("-", 2) + if len(split) < 3: # probably a tagged commit + tag = describe_output + number = 0 + node = None + else: + tag, number_, node = split + number = int(number_) return tag, number, node, dirty + + +def archival_to_version( + data: dict[str, str], config: Configuration +) -> ScmVersion | None: + node: str | None + log.debug("data %s", data) + archival_describe = data.get("describe-name", DESCRIBE_UNSUPPORTED) + if DESCRIBE_UNSUPPORTED in archival_describe: + warnings.warn("git archive did not support describe output") + else: + tag, number, node, _ = _git_parse_describe(archival_describe) + return meta( + tag, + config=config, + distance=number, + node=node, + ) + + for ref in REF_TAG_RE.findall(data.get("ref-names", "")): + version = tag_to_version(ref, config) + if version is not None: + return meta(version, config=config) + else: + node = data.get("node") + if node is None: + return None + elif "$FORMAT" in node.upper(): + warnings.warn("unprocessed git archival found (no export subst applied)") + return None + else: + return meta("0.0", node=node, config=config) + + +def parse_archival(root: _t.PathT, config: Configuration) -> ScmVersion | None: + archival = os.path.join(root, ".git_archival.txt") + data = data_from_mime(archival) + return archival_to_version(data, config=config) diff --git a/src/setuptools_scm/hacks.py b/src/setuptools_scm/hacks.py deleted file mode 100644 index 349d26f..0000000 --- a/src/setuptools_scm/hacks.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -from .utils import data_from_mime, trace -from .version import tag_to_version, meta - - -def parse_pkginfo(root, config=None): - - pkginfo = os.path.join(root, "PKG-INFO") - trace("pkginfo", pkginfo) - data = data_from_mime(pkginfo) - version = data.get("Version") - if version != "UNKNOWN": - return meta(version, preformatted=True, config=config) - - -def parse_pip_egg_info(root, config=None): - pipdir = os.path.join(root, "pip-egg-info") - if not os.path.isdir(pipdir): - return - items = os.listdir(pipdir) - trace("pip-egg-info", pipdir, items) - if not items: - return - return parse_pkginfo(os.path.join(pipdir, items[0]), config=config) - - -def fallback_version(root, config=None): - if config.parentdir_prefix_version is not None: - _, parent_name = os.path.split(os.path.abspath(root)) - if parent_name.startswith(config.parentdir_prefix_version): - version = tag_to_version( - parent_name[len(config.parentdir_prefix_version) :], config - ) - if version is not None: - return meta(str(version), preformatted=True, config=config) - if config.fallback_version is not None: - return meta(config.fallback_version, preformatted=True, config=config) diff --git a/src/setuptools_scm/hg.py b/src/setuptools_scm/hg.py index d699d45..522dfb6 100644 --- a/src/setuptools_scm/hg.py +++ b/src/setuptools_scm/hg.py @@ -1,96 +1,173 @@ +from __future__ import annotations + +import datetime +import logging import os -from .config import Configuration -from .utils import do, trace, data_from_mime, has_command -from .version import meta, tags_to_versions - - -def _hg_tagdist_normalize_tagcommit(config, tag, dist, node, branch): - dirty = node.endswith("+") - node = "h" + node.strip("+") - - # Detect changes since the specified tag - revset = ( - "(branch(.)" # look for revisions in this branch only - " and tag({tag!r})::." # after the last tag - # ignore commits that only modify .hgtags and nothing else: - " and (merge() or file('re:^(?!\\.hgtags).*$'))" - " and not tag({tag!r}))" # ignore the tagged commit itself - ).format(tag=tag) - if tag != "0.0": - commits = do( - ["hg", "log", "-r", revset, "--template", "{node|short}"], - config.absolute_root, - ) - else: - commits = True - trace("normalize", locals()) - if commits or dirty: - return meta( - tag, distance=dist, node=node, dirty=dirty, branch=branch, config=config +from pathlib import Path +from typing import TYPE_CHECKING + +from . import Configuration +from ._version_cls import Version +from .integration import data_from_mime +from .scm_workdir import Workdir +from .version import meta +from .version import ScmVersion +from .version import tag_to_version + +if TYPE_CHECKING: + from . import _types as _t + +from ._run_cmd import run as _run, require_command as _require_command + +log = logging.getLogger(__name__) + + +class HgWorkdir(Workdir): + @classmethod + def from_potential_worktree(cls, wd: _t.PathT) -> HgWorkdir | None: + res = _run(["hg", "root"], wd) + if res.returncode: + return None + return cls(Path(res.stdout)) + + def get_meta(self, config: Configuration) -> ScmVersion | None: + node: str + tags_str: str + bookmark: str + node_date_str: str + node, tags_str, bookmark, node_date_str = self.hg_log( + ".", "{node}\n{tag}\n{bookmark}\n{date|shortdate}" + ).split("\n") + + # TODO: support bookmarks and topics (but nowadays bookmarks are + # mainly used to emulate Git branches, which is already supported with + # the dedicated class GitWorkdirHgClient) + + branch, dirty_str, dirty_date = _run( + ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"], + cwd=self.path, + check=True, + ).stdout.split("\n") + dirty = bool(int(dirty_str)) + node_date = datetime.date.fromisoformat(dirty_date if dirty else node_date_str) + + if node == "0" * len(node): + log.debug("initial node %s", self.path) + return meta( + Version("0.0"), + config=config, + dirty=dirty, + branch=branch, + node_date=node_date, + ) + + node = "h" + node[:7] + + tags = tags_str.split() + if "tip" in tags: + # tip is not a real tag + tags.remove("tip") + + if tags: + tag = tag_to_version(tags[0], config) + if tag: + return meta(tag, dirty=dirty, branch=branch, config=config) + + try: + tag_str = self.get_latest_normalizable_tag() + if tag_str is None: + dist = self.get_distance_revs("") + else: + dist = self.get_distance_revs(tag_str) + + if tag_str == "null" or tag_str is None: + tag = Version("0.0") + dist += 1 + else: + tag = tag_to_version(tag_str, config=config) + assert tag is not None + + if self.check_changes_since_tag(tag_str) or dirty: + return meta( + tag, + distance=dist, + node=node, + dirty=dirty, + branch=branch, + config=config, + node_date=node_date, + ) + else: + return meta(tag, config=config, node_date=node_date) + + except ValueError as e: + log.exception("error %s", e) + pass # unpacking failed, old hg + + return None + + def hg_log(self, revset: str, template: str) -> str: + cmd = ["hg", "log", "-r", revset, "-T", template] + + return _run(cmd, cwd=self.path, check=True).stdout + + def get_latest_normalizable_tag(self) -> str | None: + # Gets all tags containing a '.' (see #229) from oldest to newest + outlines = self.hg_log( + revset="ancestors(.) and tag('re:\\.')", + template="{tags}{if(tags, '\n', '')}", + ).split() + if not outlines: + return None + tag = outlines[-1].split()[-1] + return tag + + def get_distance_revs(self, rev1: str, rev2: str = ".") -> int: + revset = f"({rev1}::{rev2})" + out = self.hg_log(revset, ".") + return len(out) - 1 + + def check_changes_since_tag(self, tag: str | None) -> bool: + if tag == "0.0" or tag is None: + return True + + revset = ( + "(branch(.)" # look for revisions in this branch only + f" and tag({tag!r})::." # after the last tag + # ignore commits that only modify .hgtags and nothing else: + " and (merge() or file('re:^(?!\\.hgtags).*$'))" + f" and not tag({tag!r}))" # ignore the tagged commit itself ) - else: - return meta(tag, config=config) - - -def parse(root, config=None): - if not config: - config = Configuration(root=root) - - if not has_command("hg"): - return - identity_data = do("hg id -i -b -t", config.absolute_root).split() - if not identity_data: - return - node = identity_data.pop(0) - branch = identity_data.pop(0) - if "tip" in identity_data: - # tip is not a real tag - identity_data.remove("tip") - tags = tags_to_versions(identity_data) - dirty = node[-1] == "+" - if tags: - return meta(tags[0], dirty=dirty, branch=branch, config=config) - - if node.strip("+") == "0" * 12: - trace("initial node", config.absolute_root) - return meta("0.0", config=config, dirty=dirty, branch=branch) - - try: - tag = get_latest_normalizable_tag(config.absolute_root) - dist = get_graph_distance(config.absolute_root, tag) - if tag == "null": - tag = "0.0" - dist = int(dist) + 1 - return _hg_tagdist_normalize_tagcommit(config, tag, dist, node, branch) - except ValueError: - pass # unpacking failed, old hg - - -def get_latest_normalizable_tag(root): - # Gets all tags containing a '.' (see #229) from oldest to newest - cmd = [ - "hg", - "log", - "-r", - "ancestors(.) and tag('re:\\.')", - "--template", - "{tags}\n", - ] - outlines = do(cmd, root).split() - if not outlines: - return "null" - tag = outlines[-1].split()[-1] - return tag - - -def get_graph_distance(root, rev1, rev2="."): - cmd = ["hg", "log", "-q", "-r", "{}::{}".format(rev1, rev2)] - out = do(cmd, root) - return len(out.strip().splitlines()) - 1 - - -def archival_to_version(data, config=None): - trace("data", data) + + return bool(self.hg_log(revset, ".")) + + +def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None: + _require_command("hg") + if os.path.exists(os.path.join(root, ".hg/git")): + res = _run(["hg", "path"], root) + if not res.returncode: + for line in res.stdout.split("\n"): + if line.startswith("default ="): + path = Path(line.split()[2]) + if path.name.endswith(".git") or (path / ".git").exists(): + from .git import _git_parse_inner + from .hg_git import GitWorkdirHgClient + + wd_hggit = GitWorkdirHgClient.from_potential_worktree(root) + if wd_hggit: + return _git_parse_inner(config, wd_hggit) + + wd = HgWorkdir.from_potential_worktree(config.absolute_root) + + if wd is None: + return None + + return wd.get_meta(config) + + +def archival_to_version(data: dict[str, str], config: Configuration) -> ScmVersion: + log.debug("data %s", data) node = data.get("node", "")[:12] if node: node = "h" + node @@ -99,15 +176,15 @@ def archival_to_version(data, config=None): elif "latesttag" in data: return meta( data["latesttag"], - distance=data["latesttagdistance"], + distance=int(data["latesttagdistance"]), node=node, config=config, ) else: - return meta("0.0", node=node, config=config) + return meta(config.version_cls("0.0"), node=node, config=config) -def parse_archival(root, config=None): +def parse_archival(root: _t.PathT, config: Configuration) -> ScmVersion: archival = os.path.join(root, ".hg_archival.txt") data = data_from_mime(archival) return archival_to_version(data, config=config) diff --git a/src/setuptools_scm/hg_git.py b/src/setuptools_scm/hg_git.py new file mode 100644 index 0000000..b6c3036 --- /dev/null +++ b/src/setuptools_scm/hg_git.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import logging +import os +from contextlib import suppress +from datetime import date +from pathlib import Path + +from . import _types as _t +from ._run_cmd import CompletedProcess as _CompletedProcess +from ._run_cmd import require_command +from ._run_cmd import run as _run +from .git import GitWorkdir +from .hg import HgWorkdir + +log = logging.getLogger(__name__) + +_FAKE_GIT_DESCRIBE_ERROR = _CompletedProcess( + "fake git describe output for hg", + 1, + "<>hg git failed to describe", +) + + +class GitWorkdirHgClient(GitWorkdir, HgWorkdir): + COMMAND = "hg" + + @classmethod + def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdirHgClient | None: + require_command("hg") + res = _run(["hg", "root"], cwd=wd).parse_success(parse=Path) + if res is None: + return None + return cls(res) + + def is_dirty(self) -> bool: + res = _run(["hg", "id", "-T", "{dirty}"], cwd=self.path, check=True) + return bool(res.stdout) + + def get_branch(self) -> str | None: + res = _run(["hg", "id", "-T", "{bookmarks}"], cwd=self.path) + if res.returncode: + log.info("branch err %s", res) + return None + return res.stdout + + def get_head_date(self) -> date | None: + return _run('hg log -r . -T "{shortdate(date)}"', cwd=self.path).parse_success( + parse=date.fromisoformat, error_msg="head date err" + ) + + def is_shallow(self) -> bool: + return False + + def fetch_shallow(self) -> None: + pass + + def get_hg_node(self) -> str | None: + res = _run('hg log -r . -T "{node}"', cwd=self.path) + if res.returncode: + return None + else: + return res.stdout + + def _hg2git(self, hg_node: str) -> str | None: + with suppress(FileNotFoundError): + with open(os.path.join(self.path, ".hg/git-mapfile")) as map_items: + for item in map_items: + if hg_node in item: + git_node, hg_node = item.split() + return git_node + return None + + def node(self) -> str | None: + hg_node = self.get_hg_node() + if hg_node is None: + return None + + git_node = self._hg2git(hg_node) + + if git_node is None: + # trying again after hg -> git + _run(["hg", "gexport"], cwd=self.path) + git_node = self._hg2git(hg_node) + + if git_node is None: + log.debug("Cannot get git node so we use hg node %s", hg_node) + + if hg_node == "0" * len(hg_node): + # mimic Git behavior + return None + + return hg_node + + return git_node[:7] + + def count_all_nodes(self) -> int: + res = _run(["hg", "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path) + return len(res.stdout) + + def default_describe(self) -> _CompletedProcess: + """ + Tentative to reproduce the output of + + `git describe --dirty --tags --long --match *[0-9]*` + + """ + res = _run( + [ + "hg", + "log", + "-r", + "(reverse(ancestors(.)) and tag(r're:v?[0-9].*'))", + "-T", + "{tags}{if(tags, ' ', '')}", + ], + cwd=self.path, + ) + if res.returncode: + return _FAKE_GIT_DESCRIBE_ERROR + hg_tags: list[str] = res.stdout.split() + + if not hg_tags: + return _FAKE_GIT_DESCRIBE_ERROR + + with self.path.joinpath(".hg/git-tags").open() as fp: + git_tags: dict[str, str] = dict(line.split()[::-1] for line in fp) + + tag: str + for hg_tag in hg_tags: + if hg_tag in git_tags: + tag = hg_tag + break + else: + logging.warning("tag not found hg=%s git=%s", hg_tags, git_tags) + return _FAKE_GIT_DESCRIBE_ERROR + + res = _run(["hg", "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path) + if res.returncode: + return _FAKE_GIT_DESCRIBE_ERROR + distance = len(res.stdout) - 1 + + node = self.node() + assert node is not None + desc = f"{tag}-{distance}-g{node}" + + if self.is_dirty(): + desc += "-dirty" + log.debug("faked describe %r", desc) + return _CompletedProcess( + ["setuptools-scm", "faked", "describe"], + returncode=0, + stdout=desc, + stderr="", + ) diff --git a/src/setuptools_scm/integration.py b/src/setuptools_scm/integration.py index c623db7..390b0a7 100644 --- a/src/setuptools_scm/integration.py +++ b/src/setuptools_scm/integration.py @@ -1,48 +1,30 @@ -from pkg_resources import iter_entry_points - -from .version import _warn_if_setuptools_outdated -from .utils import do, trace_exception -from . import _get_version, Configuration - - -def version_keyword(dist, keyword, value): - _warn_if_setuptools_outdated() - if not value: - return - if value is True: - value = {} - if getattr(value, "__call__", None): - value = value() - config = Configuration(**value) - dist.metadata.version = _get_version(config) - - -def find_files(path=""): - for ep in iter_entry_points("setuptools_scm.files_command"): - command = ep.load() - if isinstance(command, str): - # this technique is deprecated - res = do(ep.load(), path or ".").splitlines() - else: - res = command(path) - if res: - return res - return [] - - -def _args_from_toml(name="pyproject.toml"): - # todo: more sensible config initialization - # move this elper back to config and unify it with the code from get_config - - with open(name) as strm: - defn = __import__("toml").load(strm) - return defn.get("tool", {})["setuptools_scm"] - - -def infer_version(dist): - - try: - config = Configuration.from_file() - except Exception: - return trace_exception() - dist.metadata.version = _get_version(config) +from __future__ import annotations + +import logging +import textwrap +from pathlib import Path + +from . import _types as _t + +log = logging.getLogger(__name__) + + +def data_from_mime(path: _t.PathT, content: None | str = None) -> dict[str, str]: + """return a mapping from mime/pseudo-mime content + :param path: path to the mime file + :param content: content of the mime file, if None, read from path + :rtype: dict[str, str] + + """ + + if content is None: + content = Path(path).read_text(encoding="utf-8") + log.debug("mime %s content:\n%s", path, textwrap.indent(content, " ")) + + from email.parser import HeaderParser + + parser = HeaderParser() + message = parser.parsestr(content) + data = dict(message.items()) + log.debug("mime %s data:\n%s", path, data) + return data diff --git a/src/setuptools_scm/scm_workdir.py b/src/setuptools_scm/scm_workdir.py new file mode 100644 index 0000000..9879549 --- /dev/null +++ b/src/setuptools_scm/scm_workdir.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ._config import Configuration +from .version import ScmVersion + + +@dataclass() +class Workdir: + path: Path + + def run_describe(self, config: Configuration) -> ScmVersion: + raise NotImplementedError(self.run_describe) diff --git a/src/setuptools_scm/utils.py b/src/setuptools_scm/utils.py deleted file mode 100644 index c31007a..0000000 --- a/src/setuptools_scm/utils.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -utils -""" -from __future__ import print_function, unicode_literals -import inspect -import warnings -import sys -import shlex -import subprocess -import os -import io -import platform -import traceback -import datetime - - -DEBUG = bool(os.environ.get("SETUPTOOLS_SCM_DEBUG")) -IS_WINDOWS = platform.system() == "Windows" -PY2 = sys.version_info < (3,) -PY3 = sys.version_info > (3,) -string_types = (str,) if PY3 else (str, unicode) # noqa - - -def no_git_env(env): - # adapted from pre-commit - # Too many bugs dealing with environment variables and GIT: - # https://github.com/pre-commit/pre-commit/issues/300 - # In git 2.6.3 (maybe others), git exports GIT_WORK_TREE while running - # pre-commit hooks - # In git 1.9.1 (maybe others), git exports GIT_DIR and GIT_INDEX_FILE - # while running pre-commit hooks in submodules. - # GIT_DIR: Causes git clone to clone wrong thing - # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit - for k, v in env.items(): - if k.startswith("GIT_"): - trace(k, v) - return { - k: v - for k, v in env.items() - if not k.startswith("GIT_") - or k in ("GIT_EXEC_PATH", "GIT_SSH", "GIT_SSH_COMMAND") - } - - -def trace(*k): - if DEBUG: - print(*k) - sys.stdout.flush() - - -def trace_exception(): - DEBUG and traceback.print_exc() - - -def ensure_stripped_str(str_or_bytes): - if isinstance(str_or_bytes, str): - return str_or_bytes.strip() - else: - return str_or_bytes.decode("utf-8", "surrogateescape").strip() - - -def _always_strings(env_dict): - """ - On Windows and Python 2, environment dictionaries must be strings - and not unicode. - """ - if IS_WINDOWS or PY2: - env_dict.update((key, str(value)) for (key, value) in env_dict.items()) - return env_dict - - -def _popen_pipes(cmd, cwd): - return subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=str(cwd), - env=_always_strings( - dict( - no_git_env(os.environ), - # os.environ, - # try to disable i18n - LC_ALL="C", - LANGUAGE="", - HGPLAIN="1", - ) - ), - ) - - -def do_ex(cmd, cwd="."): - trace("cmd", repr(cmd)) - if os.name == "posix" and not isinstance(cmd, (list, tuple)): - cmd = shlex.split(cmd) - - p = _popen_pipes(cmd, cwd) - out, err = p.communicate() - if out: - trace("out", repr(out)) - if err: - trace("err", repr(err)) - if p.returncode: - trace("ret", p.returncode) - return ensure_stripped_str(out), ensure_stripped_str(err), p.returncode - - -def do(cmd, cwd="."): - out, err, ret = do_ex(cmd, cwd) - if ret: - print(err) - return out - - -def data_from_mime(path): - with io.open(path, encoding="utf-8") as fp: - content = fp.read() - trace("content", repr(content)) - # the complex conditions come from reading pseudo-mime-messages - data = dict(x.split(": ", 1) for x in content.splitlines() if ": " in x) - trace("data", data) - return data - - -class UTC(datetime.tzinfo): - _ZERO = datetime.timedelta(0) - - def utcoffset(self, dt): - return self._ZERO - - def tzname(self, dt): - return "UTC" - - def dst(self, dt): - return self._ZERO - - -utc = UTC() - - -def function_has_arg(fn, argname): - assert inspect.isfunction(fn) - - if PY2: - argspec = inspect.getargspec(fn).args - else: - - argspec = inspect.signature(fn).parameters - - return argname in argspec - - -def has_command(name): - try: - p = _popen_pipes([name, "help"], ".") - except OSError: - trace(*sys.exc_info()) - res = False - else: - p.communicate() - res = not p.returncode - if not res: - warnings.warn("%r was not found" % name) - return res diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py index a3c8b94..f43e14b 100644 --- a/src/setuptools_scm/version.py +++ b/src/setuptools_scm/version.py @@ -1,167 +1,159 @@ -from __future__ import print_function -import datetime -import warnings -import re - -from .config import Configuration -from .utils import trace, string_types, utc +from __future__ import annotations -from pkg_resources import iter_entry_points - -from pkg_resources import parse_version as pkg_parse_version +import dataclasses +import logging +import os +import re +import warnings +from datetime import date +from datetime import datetime +from datetime import timezone +from typing import Any +from typing import Callable +from typing import Match +from typing import TYPE_CHECKING -SEMVER_MINOR = 2 -SEMVER_PATCH = 3 -SEMVER_LEN = 3 +from . import _entrypoints +from . import _modify_version +if TYPE_CHECKING: + from typing_extensions import Concatenate + from typing_extensions import ParamSpec -def _parse_version_tag(tag, config): - tagstring = tag if not isinstance(tag, string_types) else str(tag) - match = config.tag_regex.match(tagstring) + _P = ParamSpec("_P") - result = None - if match: - if len(match.groups()) == 1: - key = 1 - else: - key = "version" +from typing import TypedDict - result = { - "version": match.group(key), - "prefix": match.group(0)[: match.start(key)], - "suffix": match.group(0)[match.end(key) :], - } - trace("tag '{}' parsed to {}".format(tag, result)) - return result +from ._version_cls import Version as PkgVersion, _VersionT +from . import _version_cls as _v +from . import _config +log = logging.getLogger(__name__) -def _get_version_class(): - modern_version = pkg_parse_version("1.0") - if isinstance(modern_version, tuple): - return None - else: - return type(modern_version) +SEMVER_MINOR = 2 +SEMVER_PATCH = 3 +SEMVER_LEN = 3 -VERSION_CLASS = _get_version_class() +class _TagDict(TypedDict): + version: str + prefix: str + suffix: str -class SetuptoolsOutdatedWarning(Warning): - pass +def _parse_version_tag( + tag: str | object, config: _config.Configuration +) -> _TagDict | None: + match = config.tag_regex.match(str(tag)) -# append so integrators can disable the warning -warnings.simplefilter("error", SetuptoolsOutdatedWarning, append=True) + if match: + key: str | int = 1 if len(match.groups()) == 1 else "version" + full = match.group(0) + log.debug("%r %r %s", tag, config.tag_regex, match) + log.debug( + "key %s data %s, %s, %r", key, match.groupdict(), match.groups(), full + ) + result = _TagDict( + version=match.group(key), + prefix=full[: match.start(key)], + suffix=full[match.end(key) :], + ) + log.debug("tag %r parsed to %r", tag, result) + assert result["version"] + return result + else: + log.debug("tag %r did not parse", tag) -def _warn_if_setuptools_outdated(): - if VERSION_CLASS is None: - warnings.warn("your setuptools is too old (<12)", SetuptoolsOutdatedWarning) + return None -def callable_or_entrypoint(group, callable_or_name): - trace("ep", (group, callable_or_name)) +def callable_or_entrypoint(group: str, callable_or_name: str | Any) -> Any: + log.debug("ep %r %r", group, callable_or_name) if callable(callable_or_name): return callable_or_name + from ._entrypoints import iter_entry_points for ep in iter_entry_points(group, callable_or_name): - trace("ep found:", ep.name) + log.debug("ep found: %s", ep.name) return ep.load() -def tag_to_version(tag, config=None): +def tag_to_version( + tag: _VersionT | str, config: _config.Configuration +) -> _VersionT | None: """ take a tag that might be prefixed with a keyword and return only the version part - :param config: optional configuration object """ - trace("tag", tag) + log.debug("tag %s", tag) - if not config: - config = Configuration() - - tagdict = _parse_version_tag(tag, config) - if not isinstance(tagdict, dict) or not tagdict.get("version", None): - warnings.warn("tag {!r} no version found".format(tag)) + tag_dict = _parse_version_tag(tag, config) + if tag_dict is None or not tag_dict.get("version", None): + warnings.warn(f"tag {tag!r} no version found") return None - version = tagdict["version"] - trace("version pre parse", version) + version_str = tag_dict["version"] + log.debug("version pre parse %s", version_str) - if tagdict.get("suffix", ""): - warnings.warn( - "tag {!r} will be stripped of its suffix '{}'".format( - tag, tagdict["suffix"] - ) - ) + if suffix := tag_dict.get("suffix", ""): + warnings.warn(f"tag {tag!r} will be stripped of its suffix {suffix!r}") - if VERSION_CLASS is not None: - version = pkg_parse_version(version) - trace("version", repr(version)) + version: _VersionT = config.version_cls(version_str) + log.debug("version=%r", version) return version -def tags_to_versions(tags, config=None): - """ - take tags that might be prefixed with a keyword and return only the version part - :param tags: an iterable of tags - :param config: optional configuration object +def _source_epoch_or_utc_now() -> datetime: + if "SOURCE_DATE_EPOCH" in os.environ: + date_epoch = int(os.environ["SOURCE_DATE_EPOCH"]) + return datetime.fromtimestamp(date_epoch, timezone.utc) + else: + return datetime.now(timezone.utc) + + +@dataclasses.dataclass +class ScmVersion: + """represents a parsed version from scm""" + + tag: _v.Version | _v.NonNormalizedVersion | str + """the related tag or preformatted version string""" + config: _config.Configuration + """the configuration used to parse the version""" + distance: int = 0 + """the number of commits since the tag""" + node: str | None = None + """the shortened node id""" + dirty: bool = False + """whether the working copy had uncommitted changes""" + preformatted: bool = False + """whether the version string was preformatted""" + branch: str | None = None + """the branch name if any""" + node_date: date | None = None + """the date of the commit if available""" + time: datetime = dataclasses.field(default_factory=_source_epoch_or_utc_now) + """the current time or source epoch time + only set for unit-testing version schemes + for real usage it must be `now(utc)` or `SOURCE_EPOCH` """ - result = [] - for tag in tags: - tag = tag_to_version(tag, config=config) - if tag: - result.append(tag) - return result - - -class ScmVersion(object): - def __init__( - self, - tag_version, - distance=None, - node=None, - dirty=False, - preformatted=False, - branch=None, - config=None, - **kw - ): - if kw: - trace("unknown args", kw) - self.tag = tag_version - if dirty and distance is None: - distance = 0 - self.distance = distance - self.node = node - self.time = datetime.datetime.now(utc) - self._extra = kw - self.dirty = dirty - self.preformatted = preformatted - self.branch = branch - self.config = config @property - def extra(self): - warnings.warn( - "ScmVersion.extra is deprecated and will be removed in future", - category=DeprecationWarning, - stacklevel=2, + def exact(self) -> bool: + """returns true checked out exactly on a tag and no local changes apply""" + return self.distance == 0 and not self.dirty + + def __repr__(self) -> str: + return ( + f"" ) - return self._extra - @property - def exact(self): - return self.distance is None - - def __repr__(self): - return self.format_with( - "" - ) - - def format_with(self, fmt, **kw): + def format_with(self, fmt: str, **kw: object) -> str: + """format a given format string with attributes of this object""" return fmt.format( time=self.time, tag=self.tag, @@ -169,81 +161,86 @@ class ScmVersion(object): node=self.node, dirty=self.dirty, branch=self.branch, - **kw + node_date=self.node_date, + **kw, ) - def format_choice(self, clean_format, dirty_format, **kw): + def format_choice(self, clean_format: str, dirty_format: str, **kw: object) -> str: + """given `clean_format` and `dirty_format` + + choose one based on `self.dirty` and format it using `self.format_with`""" + return self.format_with(dirty_format if self.dirty else clean_format, **kw) - def format_next_version(self, guess_next, fmt="{guessed}.dev{distance}", **kw): - guessed = guess_next(self.tag, **kw) + def format_next_version( + self, + guess_next: Callable[Concatenate[ScmVersion, _P], str], + fmt: str = "{guessed}.dev{distance}", + *k: _P.args, + **kw: _P.kwargs, + ) -> str: + guessed = guess_next(self, *k, **kw) return self.format_with(fmt, guessed=guessed) -def _parse_tag(tag, preformatted, config): +def _parse_tag( + tag: _VersionT | str, preformatted: bool, config: _config.Configuration +) -> _VersionT | str: if preformatted: return tag - if VERSION_CLASS is None or not isinstance(tag, VERSION_CLASS): - tag = tag_to_version(tag, config) - return tag + elif not isinstance(tag, config.version_cls): + version = tag_to_version(tag, config) + assert version is not None + return version + else: + return tag def meta( - tag, - distance=None, - dirty=False, - node=None, - preformatted=False, - branch=None, - config=None, - **kw -): - if not config: - warnings.warn( - "meta invoked without explicit configuration," - " will use defaults where required." - ) + tag: str | _VersionT, + *, + distance: int = 0, + dirty: bool = False, + node: str | None = None, + preformatted: bool = False, + branch: str | None = None, + config: _config.Configuration, + node_date: date | None = None, +) -> ScmVersion: parsed_version = _parse_tag(tag, preformatted, config) - trace("version", tag, "->", parsed_version) - assert parsed_version is not None, "cant parse version %s" % tag + log.info("version %s -> %s", tag, parsed_version) + assert parsed_version is not None, "Can't parse version %s" % tag return ScmVersion( - parsed_version, distance, node, dirty, preformatted, branch, config, **kw + parsed_version, + distance=distance, + node=node, + dirty=dirty, + preformatted=preformatted, + branch=branch, + config=config, + node_date=node_date, ) -def guess_next_version(tag_version): - version = _strip_local(str(tag_version)) - return _bump_dev(version) or _bump_regex(version) - - -def _strip_local(version_string): - public, sep, local = version_string.partition("+") - return public - +def guess_next_version(tag_version: ScmVersion) -> str: + version = _modify_version.strip_local(str(tag_version.tag)) + return _modify_version._bump_dev(version) or _modify_version._bump_regex(version) -def _bump_dev(version): - if ".dev" not in version: - return - prefix, tail = version.rsplit(".dev", 1) - assert tail == "0", "own dev numbers are unsupported" - return prefix - - -def _bump_regex(version): - prefix, tail = re.match(r"(.*?)(\d+)$", version).groups() - return "%s%d" % (prefix, int(tail) + 1) - - -def guess_next_dev_version(version): +def guess_next_dev_version(version: ScmVersion) -> str: if version.exact: return version.format_with("{tag}") else: return version.format_next_version(guess_next_version) -def guess_next_simple_semver(version, retain, increment=True): - parts = [int(i) for i in str(version).split(".")[:retain]] +def guess_next_simple_semver( + version: ScmVersion, retain: int, increment: bool = True +) -> str: + try: + parts = [int(i) for i in str(version.tag).split(".")[:retain]] + except ValueError: + raise ValueError(f"{version} can't be parsed as numeric version") from None while len(parts) < retain: parts.append(0) if increment: @@ -253,9 +250,9 @@ def guess_next_simple_semver(version, retain, increment=True): return ".".join(str(i) for i in parts) -def simplified_semver_version(version): +def simplified_semver_version(version: ScmVersion) -> str: if version.exact: - return guess_next_simple_semver(version.tag, retain=SEMVER_LEN, increment=False) + return guess_next_simple_semver(version, retain=SEMVER_LEN, increment=False) else: if version.branch is not None and "feature" in version.branch: return version.format_next_version( @@ -267,18 +264,24 @@ def simplified_semver_version(version): ) -def release_branch_semver_version(version): +def release_branch_semver_version(version: ScmVersion) -> str: if version.exact: return version.format_with("{tag}") if version.branch is not None: # Does the branch name (stripped of namespace) parse as a version? - branch_ver = _parse_version_tag(version.branch.split("/")[-1], version.config) - if branch_ver is not None: + branch_ver_data = _parse_version_tag( + version.branch.split("/")[-1], version.config + ) + if branch_ver_data is not None: + branch_ver = branch_ver_data["version"] + if branch_ver[0] == "v": + # Allow branches that start with 'v', similar to Version. + branch_ver = branch_ver[1:] # Does the branch version up to the minor part match the tag? If not it # might be like, an issue number or something and not a version number, so # we only want to use it if it matches. tag_ver_up_to_minor = str(version.tag).split(".")[:SEMVER_MINOR] - branch_ver_up_to_minor = branch_ver["version"].split(".")[:SEMVER_MINOR] + branch_ver_up_to_minor = branch_ver.split(".")[:SEMVER_MINOR] if branch_ver_up_to_minor == tag_ver_up_to_minor: # We're in a release/maintenance branch, next is a patch/rc/beta bump: return version.format_next_version(guess_next_version) @@ -286,64 +289,152 @@ def release_branch_semver_version(version): return version.format_next_version(guess_next_simple_semver, retain=SEMVER_MINOR) -def release_branch_semver(version): +def release_branch_semver(version: ScmVersion) -> str: warnings.warn( - "release_branch_semver is deprecated and will be removed in future. " - + "Use release_branch_semver_version instead", + "release_branch_semver is deprecated and will be removed in the future. " + "Use release_branch_semver_version instead", category=DeprecationWarning, stacklevel=2, ) return release_branch_semver_version(version) -def _format_local_with_time(version, time_format): +def no_guess_dev_version(version: ScmVersion) -> str: + if version.exact: + return version.format_with("{tag}") + else: + return version.format_next_version(_modify_version._dont_guess_next_version) + + +_DATE_REGEX = re.compile( + r""" + ^(?P + (?P[vV]?) + (?P\d{2}|\d{4})(?:\.\d{1,2}){2}) + (?:\.(?P\d*))?$ + """, + re.VERBOSE, +) + + +def date_ver_match(ver: str) -> Match[str] | None: + return _DATE_REGEX.match(ver) + - if version.exact or version.node is None: - return version.format_choice( - "", "+d{time:{time_format}}", time_format=time_format +def guess_next_date_ver( + version: ScmVersion, + node_date: date | None = None, + date_fmt: str | None = None, + version_cls: type | None = None, +) -> str: + """ + same-day -> patch +1 + other-day -> today + + distance is always added as .devX + """ + match = date_ver_match(str(version.tag)) + if match is None: + warnings.warn( + f"{version} does not correspond to a valid versioning date, " + "assuming legacy version" ) + if date_fmt is None: + date_fmt = "%y.%m.%d" else: - return version.format_choice( - "+{node}", "+{node}.d{time:{time_format}}", time_format=time_format + # deduct date format if not provided + if date_fmt is None: + date_fmt = "%Y.%m.%d" if len(match.group("year")) == 4 else "%y.%m.%d" + if prefix := match.group("prefix"): + if not date_fmt.startswith(prefix): + date_fmt = prefix + date_fmt + + today = version.time.date() + head_date = node_date or today + # compute patch + if match is None: + tag_date = today + else: + tag_date = ( + datetime.strptime(match.group("date"), date_fmt) + .replace(tzinfo=timezone.utc) + .date() ) + if tag_date == head_date: + patch = "0" if match is None else (match.group("patch") or "0") + patch = int(patch) + 1 + else: + if tag_date > head_date and match is not None: + # warn on future times + warnings.warn( + f"your previous tag ({tag_date})" + f" is ahead your node date ({head_date})" + ) + patch = 0 + next_version = "{node_date:{date_fmt}}.{patch}".format( + node_date=head_date, date_fmt=date_fmt, patch=patch + ) + # rely on the Version object to ensure consistency (e.g. remove leading 0s) + if version_cls is None: + version_cls = PkgVersion + next_version = str(version_cls(next_version)) + return next_version + + +def calver_by_date(version: ScmVersion) -> str: + if version.exact and not version.dirty: + return version.format_with("{tag}") + # TODO: move the release-X check to a new scheme + if version.branch is not None and version.branch.startswith("release-"): + branch_ver = _parse_version_tag(version.branch.split("-")[-1], version.config) + if branch_ver is not None: + ver = branch_ver["version"] + match = date_ver_match(ver) + if match: + return ver + return version.format_next_version( + guess_next_date_ver, + node_date=version.node_date, + version_cls=version.config.version_cls, + ) -def get_local_node_and_date(version): - return _format_local_with_time(version, time_format="%Y%m%d") +def get_local_node_and_date(version: ScmVersion) -> str: + return _modify_version._format_local_with_time(version, time_format="%Y%m%d") -def get_local_node_and_timestamp(version, fmt="%Y%m%d%H%M%S"): - return _format_local_with_time(version, time_format=fmt) +def get_local_node_and_timestamp(version: ScmVersion) -> str: + return _modify_version._format_local_with_time(version, time_format="%Y%m%d%H%M%S") -def get_local_dirty_tag(version): +def get_local_dirty_tag(version: ScmVersion) -> str: return version.format_choice("", "+dirty") -def get_no_local_node(_): +def get_no_local_node(version: ScmVersion) -> str: return "" -def postrelease_version(version): +def postrelease_version(version: ScmVersion) -> str: if version.exact: return version.format_with("{tag}") else: return version.format_with("{tag}.post{distance}") -def format_version(version, **config): - trace("scm version", version) - trace("config", config) +def format_version(version: ScmVersion) -> str: + log.debug("scm version %s", version) + log.debug("config %s", version.config) if version.preformatted: + assert isinstance(version.tag, str) return version.tag - version_scheme = callable_or_entrypoint( - "setuptools_scm.version_scheme", config["version_scheme"] + main_version = _entrypoints._call_version_scheme( + version, "setuptools_scm.version_scheme", version.config.version_scheme, None ) - local_scheme = callable_or_entrypoint( - "setuptools_scm.local_scheme", config["local_scheme"] + log.debug("version %s", main_version) + assert main_version is not None + local_version = _entrypoints._call_version_scheme( + version, "setuptools_scm.local_scheme", version.config.local_scheme, "+unknown" ) - main_version = version_scheme(version) - trace("version", main_version) - local_version = local_scheme(version) - trace("local_version", local_version) - return version_scheme(version) + local_scheme(version) + log.debug("local_version %s", local_version) + return main_version + local_version diff --git a/src/setuptools_scm/win_py31_compat.py b/src/setuptools_scm/win_py31_compat.py deleted file mode 100644 index 82a11eb..0000000 --- a/src/setuptools_scm/win_py31_compat.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Backport of os.path.samefile for Python prior to 3.2 -on Windows from jaraco.windows 3.8. - -DON'T EDIT THIS FILE! - -Instead, file tickets and PR's with `jaraco.windows -`_ and request -a port to setuptools_scm. -""" - -import os -import nt -import posixpath -import ctypes.wintypes -import sys -import __builtin__ as builtins - - -## -# From jaraco.windows.error - -def format_system_message(errno): - """ - Call FormatMessage with a system error number to retrieve - the descriptive error message. - """ - # first some flags used by FormatMessageW - ALLOCATE_BUFFER = 0x100 - FROM_SYSTEM = 0x1000 - - # Let FormatMessageW allocate the buffer (we'll free it below) - # Also, let it know we want a system error message. - flags = ALLOCATE_BUFFER | FROM_SYSTEM - source = None - message_id = errno - language_id = 0 - result_buffer = ctypes.wintypes.LPWSTR() - buffer_size = 0 - arguments = None - bytes = ctypes.windll.kernel32.FormatMessageW( - flags, - source, - message_id, - language_id, - ctypes.byref(result_buffer), - buffer_size, - arguments, - ) - # note the following will cause an infinite loop if GetLastError - # repeatedly returns an error that cannot be formatted, although - # this should not happen. - handle_nonzero_success(bytes) - message = result_buffer.value - ctypes.windll.kernel32.LocalFree(result_buffer) - return message - - -class WindowsError(builtins.WindowsError): - """ - More info about errors at - http://msdn.microsoft.com/en-us/library/ms681381(VS.85).aspx - """ - - def __init__(self, value=None): - if value is None: - value = ctypes.windll.kernel32.GetLastError() - strerror = format_system_message(value) - if sys.version_info > (3, 3): - args = 0, strerror, None, value - else: - args = value, strerror - super(WindowsError, self).__init__(*args) - - @property - def message(self): - return self.strerror - - @property - def code(self): - return self.winerror - - def __str__(self): - return self.message - - def __repr__(self): - return '{self.__class__.__name__}({self.winerror})'.format(**vars()) - - -def handle_nonzero_success(result): - if result == 0: - raise WindowsError() - - -## -# From jaraco.windows.api.filesystem - -FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 -FILE_FLAG_BACKUP_SEMANTICS = 0x2000000 -OPEN_EXISTING = 3 -FILE_ATTRIBUTE_NORMAL = 0x80 -FILE_READ_ATTRIBUTES = 0x80 -INVALID_HANDLE_VALUE = ctypes.wintypes.HANDLE(-1).value - - -class BY_HANDLE_FILE_INFORMATION(ctypes.Structure): - _fields_ = [ - ('file_attributes', ctypes.wintypes.DWORD), - ('creation_time', ctypes.wintypes.FILETIME), - ('last_access_time', ctypes.wintypes.FILETIME), - ('last_write_time', ctypes.wintypes.FILETIME), - ('volume_serial_number', ctypes.wintypes.DWORD), - ('file_size_high', ctypes.wintypes.DWORD), - ('file_size_low', ctypes.wintypes.DWORD), - ('number_of_links', ctypes.wintypes.DWORD), - ('file_index_high', ctypes.wintypes.DWORD), - ('file_index_low', ctypes.wintypes.DWORD), - ] - - @property - def file_size(self): - return (self.file_size_high << 32) + self.file_size_low - - @property - def file_index(self): - return (self.file_index_high << 32) + self.file_index_low - - -class SECURITY_ATTRIBUTES(ctypes.Structure): - _fields_ = ( - ('length', ctypes.wintypes.DWORD), - ('p_security_descriptor', ctypes.wintypes.LPVOID), - ('inherit_handle', ctypes.wintypes.BOOLEAN), - ) - - -LPSECURITY_ATTRIBUTES = ctypes.POINTER(SECURITY_ATTRIBUTES) - - -CreateFile = ctypes.windll.kernel32.CreateFileW -CreateFile.argtypes = ( - ctypes.wintypes.LPWSTR, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - LPSECURITY_ATTRIBUTES, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - ctypes.wintypes.HANDLE, -) -CreateFile.restype = ctypes.wintypes.HANDLE - -GetFileInformationByHandle = ctypes.windll.kernel32.GetFileInformationByHandle -GetFileInformationByHandle.restype = ctypes.wintypes.BOOL -GetFileInformationByHandle.argtypes = ( - ctypes.wintypes.HANDLE, - ctypes.POINTER(BY_HANDLE_FILE_INFORMATION), -) - - -## -# From jaraco.windows.filesystem - -def compat_stat(path): - """ - Generate stat as found on Python 3.2 and later. - """ - stat = os.stat(path) - info = get_file_info(path) - # rewrite st_ino, st_dev, and st_nlink based on file info - return nt.stat_result( - (stat.st_mode,) + - (info.file_index, info.volume_serial_number, info.number_of_links) + - stat[4:] - ) - - -def samefile(f1, f2): - """ - Backport of samefile from Python 3.2 with support for Windows. - """ - return posixpath.samestat(compat_stat(f1), compat_stat(f2)) - - -def get_file_info(path): - # open the file the same way CPython does in posixmodule.c - desired_access = FILE_READ_ATTRIBUTES - share_mode = 0 - security_attributes = None - creation_disposition = OPEN_EXISTING - flags_and_attributes = ( - FILE_ATTRIBUTE_NORMAL | - FILE_FLAG_BACKUP_SEMANTICS | - FILE_FLAG_OPEN_REPARSE_POINT - ) - template_file = None - - handle = CreateFile( - path, - desired_access, - share_mode, - security_attributes, - creation_disposition, - flags_and_attributes, - template_file, - ) - - if handle == INVALID_HANDLE_VALUE: - raise WindowsError() - - info = BY_HANDLE_FILE_INFORMATION() - res = GetFileInformationByHandle(handle, info) - handle_nonzero_success(res) - - return info diff --git a/testing/Dockerfile.busted-buster b/testing/Dockerfile.busted-buster new file mode 100644 index 0000000..872833f --- /dev/null +++ b/testing/Dockerfile.busted-buster @@ -0,0 +1,3 @@ +FROM debian:buster +RUN apt-get update -q && apt-get install -yq python3-pip python3-setuptools +RUN printf "[easy_install]\nallow_hosts=localhost\nfind_links=/dist\n" > /root/.pydistutils.cfg diff --git a/testing/Dockerfile.rawhide-git b/testing/Dockerfile.rawhide-git new file mode 100644 index 0000000..d9f4ddc --- /dev/null +++ b/testing/Dockerfile.rawhide-git @@ -0,0 +1,7 @@ +FROM registry.fedoraproject.org/fedora:rawhide +RUN dnf install git -y +RUN git --version +USER 1000:1000 +VOLUME /repo +WORKDIR /repo +ENTRYPOINT mkdir git-archived && git archive HEAD -o git-archived/archival.tar.gz diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testing/check_self_install.py b/testing/check_self_install.py deleted file mode 100644 index de3ac79..0000000 --- a/testing/check_self_install.py +++ /dev/null @@ -1,5 +0,0 @@ -import pkg_resources -import setuptools_scm - -dist = pkg_resources.get_distribution("setuptools_scm") -assert dist.version == setuptools_scm.get_version(), dist.version diff --git a/testing/conftest.py b/testing/conftest.py index 5cb65f6..05ab534 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,96 +1,106 @@ +from __future__ import annotations + +import contextlib import os -import itertools +from pathlib import Path +from types import TracebackType +from typing import Any +from typing import Iterator + import pytest -import six +from typing_extensions import Self + +from .wd_wrapper import WorkDir +from setuptools_scm._run_cmd import run + + +def pytest_configure() -> None: + # 2009-02-13T23:31:30+00:00 + os.environ["SOURCE_DATE_EPOCH"] = "1234567890" + os.environ["SETUPTOOLS_SCM_DEBUG"] = "1" + -os.environ["SETUPTOOLS_SCM_DEBUG"] = "1" -VERSION_PKGS = ["setuptools", "setuptools_scm"] +VERSION_PKGS = ["setuptools", "setuptools_scm", "packaging", "build", "wheel"] -def pytest_report_header(): - import pkg_resources +def pytest_report_header() -> list[str]: + from importlib.metadata import version res = [] for pkg in VERSION_PKGS: - version = pkg_resources.get_distribution(pkg).version + pkg_version = version(pkg) path = __import__(pkg).__file__ - res.append("{} version {} from {!r}".format(pkg, version, path)) + res.append(f"{pkg} version {pkg_version} from {path!r}") return res -class Wd(object): - commit_command = None - add_command = None +def pytest_addoption(parser: Any) -> None: + group = parser.getgroup("setuptools_scm") + group.addoption( + "--test-legacy", dest="scm_test_virtualenv", default=False, action="store_true" + ) - def __repr__(self): - return "".format(cwd=self.cwd) - def __init__(self, cwd): - self.cwd = cwd - self.__counter = itertools.count() +class DebugMode(contextlib.AbstractContextManager): # type: ignore[type-arg] + from setuptools_scm import _log as __module - def __call__(self, cmd, **kw): - if kw: - cmd = cmd.format(**kw) - from setuptools_scm.utils import do + def __init__(self) -> None: + self.__stack = contextlib.ExitStack() - return do(cmd, self.cwd) + def __enter__(self) -> Self: + self.enable() + return self - def write(self, name, value, **kw): - filename = self.cwd / name - if kw: - value = value.format(**kw) - if isinstance(value, six.text_type): - filename.write_text(value) - else: - filename.write_bytes(value) - return filename + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.disable() - def _reason(self, given_reason): - if given_reason is None: - return "number-{c}".format(c=next(self.__counter)) - else: - return given_reason + def enable(self) -> None: + self.__stack.enter_context(self.__module.defer_to_pytest()) - def add_and_commit(self, reason=None): - self(self.add_command) - self.commit(reason) + def disable(self) -> None: + self.__stack.close() - def commit(self, reason=None): - reason = self._reason(reason) - self(self.commit_command, reason=reason) - def commit_testfile(self, reason=None): - reason = self._reason(reason) - self.write("test.txt", "test {reason}", reason=reason) - self(self.add_command) - self.commit(reason=reason) +@pytest.fixture(autouse=True) +def debug_mode() -> Iterator[DebugMode]: + with DebugMode() as debug_mode: + yield debug_mode - def get_version(self, **kw): - __tracebackhide__ = True - from setuptools_scm import get_version - version = get_version(root=str(self.cwd), fallback_root=str(self.cwd), **kw) - print(version) - return version +@pytest.fixture() +def wd(tmp_path: Path) -> WorkDir: + target_wd = tmp_path.resolve() / "wd" + target_wd.mkdir() + return WorkDir(target_wd) - @property - def version(self): - __tracebackhide__ = True - return self.get_version() +@pytest.fixture() +def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]: + tmp_path = tmp_path.resolve() + path_git = tmp_path / "repo_git" + path_git.mkdir() -@pytest.yield_fixture(autouse=True) -def debug_mode(): - from setuptools_scm import utils + wd = WorkDir(path_git) + wd("git init") + wd("git config user.email test@example.com") + wd('git config user.name "a test"') + wd.add_command = "git add ." + wd.commit_command = "git commit -m test-{reason}" - utils.DEBUG = True - yield - utils.DEBUG = False + path_hg = tmp_path / "repo_hg" + run(["hg", "clone", path_git, path_hg, "--config", "extensions.hggit="], tmp_path) + assert path_hg.exists() + with open(path_hg / ".hg/hgrc", "a") as file: + file.write("[extensions]\nhggit =\n") -@pytest.fixture -def wd(tmp_path): - target_wd = tmp_path.resolve() / "wd" - target_wd.mkdir() - return Wd(target_wd) + wd_hg = WorkDir(path_hg) + wd_hg.add_command = "hg add ." + wd_hg.commit_command = 'hg commit -m test-{reason} -u test -d "0 0"' + + return wd_hg, wd diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py index c5104f5..26f87b1 100644 --- a/testing/test_basic_api.py +++ b/testing/test_basic_api.py @@ -1,117 +1,264 @@ +from __future__ import annotations + import os import sys -import py +from datetime import date +from pathlib import Path + import pytest import setuptools_scm +from setuptools_scm import Configuration from setuptools_scm import dump_version -from setuptools_scm.utils import data_from_mime, do +from setuptools_scm._run_cmd import run +from setuptools_scm.integration import data_from_mime +from setuptools_scm.version import meta +from setuptools_scm.version import ScmVersion +from testing.wd_wrapper import WorkDir + + +c = Configuration() + +template = """\ +__version__ = version = {version!r} +__version_tuple__ = version_tuple = {version_tuple!r} +__sha__ = {scm_version.node!r} +""" -@pytest.mark.parametrize("cmd", ["ls", "dir"]) -def test_do(cmd, tmpdir): - if not py.path.local.sysfind(cmd): - pytest.skip(cmd + " not found") - do(cmd, str(tmpdir)) +def test_run_plain(tmp_path: Path) -> None: + run([sys.executable, "-c", "print(1)"], cwd=tmp_path) -def test_data_from_mime(tmpdir): - tmpfile = tmpdir.join("test.archival") - tmpfile.write("name: test\nrevision: 1") +def test_data_from_mime(tmp_path: Path) -> None: + tmpfile = tmp_path.joinpath("test.archival") + tmpfile.write_text("name: test\nrevision: 1") res = data_from_mime(str(tmpfile)) assert res == {"name": "test", "revision": "1"} -def test_version_from_pkginfo(wd, monkeypatch): +def test_version_from_pkginfo(wd: WorkDir) -> None: wd.write("PKG-INFO", "Version: 0.1") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" # replicate issue 167 assert wd.get_version(version_scheme="1.{0.distance}.0".format) == "0.1" -def assert_root(monkeypatch, expected_root): +def assert_root(monkeypatch: pytest.MonkeyPatch, expected_root: str) -> None: """ Patch version_from_scm to simply assert that root is expected root """ - def assertion(config): + def assertion(config: Configuration) -> ScmVersion: assert config.absolute_root == expected_root + return ScmVersion("1.0", config=config) - monkeypatch.setattr(setuptools_scm, "_do_parse", assertion) + monkeypatch.setattr(setuptools_scm._get_version_impl, "parse_version", assertion) -def test_root_parameter_creation(monkeypatch): +def test_root_parameter_creation(monkeypatch: pytest.MonkeyPatch) -> None: assert_root(monkeypatch, os.getcwd()) setuptools_scm.get_version() -def test_version_from_scm(wd): - with pytest.warns(DeprecationWarning, match=".*version_from_scm.*"): - setuptools_scm.version_from_scm(str(wd)) +def test_root_parameter_pass_by( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + assert_root(monkeypatch, os.fspath(tmp_path)) + setuptools_scm.get_version(root=os.fspath(tmp_path)) + setuptools_scm.get_version( + os.fspath(tmp_path) + ) # issue 669 - posarg difference between Configuration and get_version -def test_root_parameter_pass_by(monkeypatch, tmpdir): - assert_root(monkeypatch, tmpdir) - setuptools_scm.get_version(root=tmpdir.strpath) +def test_parentdir_prefix(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") + p = tmp_path.joinpath("projectname-v12.34") + p.mkdir() + p.joinpath("setup.py").write_text( + """from setuptools import setup +setup(use_scm_version={"parentdir_prefix_version": "projectname-"}) +""" + ) + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "12.34" -def test_parentdir_prefix(tmpdir, monkeypatch): +def test_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") - p = tmpdir.ensure("projectname-v12.34", dir=True) - p.join("setup.py").write( + p = tmp_path / "sub/package" + p.mkdir(parents=True) + p.joinpath("setup.py").write_text( """from setuptools import setup -setup(use_scm_version={"parentdir_prefix_version": "projectname-"}) +setup(use_scm_version={"fallback_version": "12.34"}) """ ) - res = do((sys.executable, "setup.py", "--version"), p) - assert res == "12.34" + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "12.34" -def test_fallback(tmpdir, monkeypatch): +def test_empty_pretend_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") - p = tmpdir.ensure("sub/package", dir=1) - p.join("setup.py").write( + monkeypatch.setenv("SETUPTOOLS_SCM_PRETEND_VERSION", "") + p = tmp_path / "sub/package" + p.mkdir(parents=True) + p.joinpath("setup.py").write_text( """from setuptools import setup setup(use_scm_version={"fallback_version": "12.34"}) """ ) - res = do((sys.executable, "setup.py", "--version"), p) - assert res == "12.34" + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "12.34" + + +def test_empty_pretend_version_named( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") + monkeypatch.setenv("SETUPTOOLS_SCM_PRETEND_VERSION", "1.23") + monkeypatch.setenv("SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MYSCM", "") + p = tmp_path.joinpath("sub/package") + p.mkdir(parents=True) + + p.joinpath("setup.py").write_text( + """from setuptools import setup +setup(name="myscm", use_scm_version={"fallback_version": "12.34"}) +""" + ) + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "12.34" + + +def test_get_version_blank_tag_regex() -> None: + with pytest.warns( + DeprecationWarning, match="empty regex for tag regex is invalid, using default" + ): + setuptools_scm.get_version(tag_regex="") @pytest.mark.parametrize( "version", ["1.0", "1.2.3.dev1+ge871260", "1.2.3.dev15+ge871260.d20180625", "2345"] ) -def test_pretended(version, monkeypatch): - monkeypatch.setenv(setuptools_scm.PRETEND_KEY, version) +def test_pretended(version: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(setuptools_scm._overrides.PRETEND_KEY, version) assert setuptools_scm.get_version() == version -def test_root_relative_to(monkeypatch, tmpdir): - assert_root(monkeypatch, tmpdir.join("alt").strpath) - __file__ = tmpdir.join("module/file.py").strpath - setuptools_scm.get_version(root="../alt", relative_to=__file__) +def test_root_relative_to(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + tmp_path.joinpath("setup.cfg").touch() + assert_root(monkeypatch, str(tmp_path / "alt")) + module = tmp_path / "module/file.py" + module.parent.mkdir() + module.touch() + setuptools_scm.get_version( + root="../alt", + relative_to=str(module), + ) + with pytest.warns(UserWarning, match="relative_to is expected to be a file.*"): + setuptools_scm.get_version( + root="../alt", + relative_to=str(module.parent), + ) + + +def test_dump_version(tmp_path: Path) -> None: + version = "1.0" + scm_version = meta(version, config=c) + dump_version(tmp_path, version, "first.txt", scm_version=scm_version) + + def read(name: str) -> str: + return tmp_path.joinpath(name).read_text() + + assert read("first.txt") == "1.0" + + version = "1.0.dev42" + scm_version = meta("1.0", distance=42, config=c) + dump_version(tmp_path, version, "first.py", scm_version=scm_version) + lines = read("first.py").splitlines() + assert lines[-2:] == [ + "__version__ = version = '1.0.dev42'", + "__version_tuple__ = version_tuple = (1, 0, 'dev42')", + ] + + version = "1.0.1+g4ac9d2c" + scm_version = meta("1.0.1", node="g4ac9d2c", config=c) + dump_version( + tmp_path, version, "second.py", scm_version=scm_version, template=template + ) + lines = read("second.py").splitlines() + assert "__version__ = version = '1.0.1+g4ac9d2c'" in lines + assert "__version_tuple__ = version_tuple = (1, 0, 1, 'g4ac9d2c')" in lines + assert "__sha__ = 'g4ac9d2c'" in lines + + version = "1.2.3.dev18+gb366d8b.d20210415" + scm_version = meta( + "1.2.3", node="gb366d8b", distance=18, node_date=date(2021, 4, 15), config=c + ) + dump_version( + tmp_path, version, "third.py", scm_version=scm_version, template=template + ) + lines = read("third.py").splitlines() + assert "__version__ = version = '1.2.3.dev18+gb366d8b.d20210415'" in lines + assert ( + "__version_tuple__ = version_tuple = (1, 2, 3, 'dev18', 'gb366d8b.d20210415')" + in lines + ) + assert "__sha__ = 'gb366d8b'" in lines -def test_dump_version(tmpdir): - sp = tmpdir.strpath - - dump_version(sp, "1.0", "first.txt") - assert tmpdir.join("first.txt").read() == "1.0" - dump_version(sp, "1.0", "first.py") - content = tmpdir.join("first.py").read() - assert repr("1.0") in content import ast - ast.parse(content) + ast.parse(read("third.py")) -def test_parse_plain_fails(recwarn): - def parse(root): +def test_parse_plain_fails(recwarn: pytest.WarningsRecorder) -> None: + def parse(root: object) -> str: return "tricked you" with pytest.raises(TypeError): setuptools_scm.get_version(parse=parse) + + +def test_custom_version_cls() -> None: + """Test that `normalize` and `version_cls` work as expected""" + + class MyVersion: + def __init__(self, tag_str: str) -> None: + self.version = tag_str + + def __repr__(self) -> str: + return f"hello,{self.version}" + + # you can not use normalize=False and version_cls at the same time + with pytest.raises( + ValueError, + match="Providing a custom `version_cls`" + " is not permitted when `normalize=False`", + ): + setuptools_scm.get_version(normalize=False, version_cls=MyVersion) + + # TODO unfortunately with PRETEND_KEY the preformatted flag becomes True + # which bypasses our class. which other mechanism would be ok to use here + # to create a test? + # monkeypatch.setenv(setuptools_scm.PRETEND_KEY, "1.0.1") + # assert setuptools_scm.get_version(version_cls=MyVersion) == "1" + + +def test_internal_get_version_warns_for_version_files(tmp_path: Path) -> None: + tmp_path.joinpath("PKG-INFO").write_text("Version: 0.1") + c = Configuration(root=tmp_path, fallback_root=tmp_path) + with pytest.warns( + DeprecationWarning, + match="force_write_version_files ought to be set," + " presuming the legacy True value", + ): + ver = setuptools_scm._get_version(c) + assert ver == "0.1" + + # force write won't write as no version file is configured + assert setuptools_scm._get_version(c, force_write_version_files=False) == ver + + assert setuptools_scm._get_version(c, force_write_version_files=True) == ver diff --git a/testing/test_cli.py b/testing/test_cli.py new file mode 100644 index 0000000..cc5a0ef --- /dev/null +++ b/testing/test_cli.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import io +from contextlib import redirect_stdout + +import pytest + +from .conftest import DebugMode +from .test_git import wd as wd_fixture # NOQA evil fixture reuse +from .wd_wrapper import WorkDir +from setuptools_scm._cli import main + + +PYPROJECT_TOML = "pyproject.toml" +PYPROJECT_SIMPLE = "[tool.setuptools_scm]" +PYPROJECT_ROOT = '[tool.setuptools_scm]\nroot=".."' + + +def get_output(args: list[str]) -> str: + with redirect_stdout(io.StringIO()) as out: + main(args) + return out.getvalue() + + +warns_cli_root_override = pytest.warns( + UserWarning, match="root .. is overridden by the cli arg ." +) +warns_absolute_root_override = pytest.warns( + UserWarning, match="absolute root path '.*' overrides relative_to '.*'" +) + +exits_with_not_found = pytest.raises(SystemExit, match="no version found for") + + +def test_cli_find_pyproject( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode +) -> None: + debug_mode.disable() + wd.commit_testfile() + wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE) + monkeypatch.chdir(wd.cwd) + + out = get_output([]) + assert out.startswith("0.1.dev1+") + + with exits_with_not_found: + get_output(["--root=.."]) + + wd.write(PYPROJECT_TOML, PYPROJECT_ROOT) + with exits_with_not_found: + print(get_output(["-c", PYPROJECT_TOML])) + + with exits_with_not_found, warns_absolute_root_override: + get_output(["-c", PYPROJECT_TOML, "--root=.."]) + + with warns_cli_root_override: + out = get_output(["-c", PYPROJECT_TOML, "--root=."]) + assert out.startswith("0.1.dev1+") diff --git a/testing/test_config.py b/testing/test_config.py index 49f1d7a..668ddd0 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1,12 +1,16 @@ -from __future__ import unicode_literals +from __future__ import annotations -from setuptools_scm.config import Configuration import re +import textwrap +from pathlib import Path + import pytest +from setuptools_scm import Configuration + @pytest.mark.parametrize( - "tag, expected_version", + ("tag", "expected_version"), [ ("apache-arrow-0.9.0", "0.9.0"), ("arrow-0.9.0", "0.9.0"), @@ -19,7 +23,7 @@ import pytest ("V1.1", "V1.1"), ], ) -def test_tag_regex(tag, expected_version): +def test_tag_regex(tag: str, expected_version: str) -> None: config = Configuration() match = config.tag_regex.match(tag) assert match @@ -27,13 +31,69 @@ def test_tag_regex(tag, expected_version): assert version == expected_version -def test_config_from_pyproject(tmpdir): - fn = tmpdir / "pyproject.toml" - fn.write_text("[tool.setuptools_scm]\n", encoding="utf-8") +def test_config_from_pyproject(tmp_path: Path) -> None: + fn = tmp_path / "pyproject.toml" + fn.write_text( + textwrap.dedent( + """ + [tool.setuptools_scm] + [project] + description = "Factory ⸻ A code generator 🏭" + authors = [{name = "Łukasz Langa"}] + """ + ), + encoding="utf-8", + ) assert Configuration.from_file(str(fn)) -def test_config_regex_init(): +def test_config_regex_init() -> None: tag_regex = re.compile(r"v(\d+)") conf = Configuration(tag_regex=tag_regex) assert conf.tag_regex is tag_regex + + +def test_config_from_file_protects_relative_to(tmp_path: Path) -> None: + fn = tmp_path / "pyproject.toml" + fn.write_text( + textwrap.dedent( + """ + [tool.setuptools_scm] + relative_to = "dont_use_me" + [project] + description = "Factory ⸻ A code generator 🏭" + authors = [{name = "Łukasz Langa"}] + """ + ), + encoding="utf-8", + ) + with pytest.warns( + UserWarning, + match=".*pyproject.toml: at \\[tool.setuptools_scm\\]\n" + "ignoring value relative_to='dont_use_me'" + " as its always relative to the config file", + ): + assert Configuration.from_file(str(fn)) + + +def test_config_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fn = tmp_path / "pyproject.toml" + fn.write_text( + textwrap.dedent( + """ + [tool.setuptools_scm] + root = "." + [project] + name = "teSt-.a" + """ + ), + encoding="utf-8", + ) + pristine = Configuration.from_file(fn) + monkeypatch.setenv( + "SETUPTOOLS_SCM_OVERRIDES_FOR_TEST_A", '{root="..", fallback_root=".."}' + ) + overriden = Configuration.from_file(fn) + + assert pristine.root != overriden.root + assert pristine.fallback_root != overriden.fallback_root diff --git a/testing/test_file_finder.py b/testing/test_file_finder.py index 463d3d4..21b523a 100644 --- a/testing/test_file_finder.py +++ b/testing/test_file_finder.py @@ -1,16 +1,21 @@ +from __future__ import annotations + import os import sys +from typing import Iterable import pytest -from setuptools_scm.integration import find_files +from .wd_wrapper import WorkDir +from setuptools_scm._file_finders import find_files @pytest.fixture(params=["git", "hg"]) -def inwd(request, wd, monkeypatch): - if request.param == "git": - if sys.platform == "win32" and sys.version_info[0] < 3: - pytest.skip("Long/short path names supported on Windows Python 2.7") +def inwd( + request: pytest.FixtureRequest, wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> WorkDir: + param: str = request.param # type: ignore + if param == "git": try: wd("git init") except OSError: @@ -19,7 +24,7 @@ def inwd(request, wd, monkeypatch): wd('git config user.name "a test"') wd.add_command = "git add ." wd.commit_command = "git commit -m test-{reason}" - elif request.param == "hg": + elif param == "hg": try: wd("hg init") except OSError: @@ -33,28 +38,29 @@ def inwd(request, wd, monkeypatch): bdir = wd.cwd / "bdir" bdir.mkdir() (bdir / "fileb").touch() - wd.add_and_commit() + if request.node.get_closest_marker("skip_commit") is None: + wd.add_and_commit() monkeypatch.chdir(wd.cwd) - yield wd + return wd -def _sep(paths): +def _sep(paths: Iterable[str]) -> set[str]: return {path.replace("/", os.path.sep) for path in paths} -def test_basic(inwd): +def test_basic(inwd: WorkDir) -> None: assert set(find_files()) == _sep({"file1", "adir/filea", "bdir/fileb"}) assert set(find_files(".")) == _sep({"./file1", "./adir/filea", "./bdir/fileb"}) assert set(find_files("adir")) == _sep({"adir/filea"}) -def test_whitespace(inwd): +def test_whitespace(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "space file").touch() inwd.add_and_commit() assert set(find_files("adir")) == _sep({"adir/space file", "adir/filea"}) -def test_case(inwd): +def test_case(inwd: WorkDir) -> None: (inwd.cwd / "CamelFile").touch() (inwd.cwd / "file2").touch() inwd.add_and_commit() @@ -64,14 +70,14 @@ def test_case(inwd): @pytest.mark.skipif(sys.platform == "win32", reason="symlinks to dir not supported") -def test_symlink_dir(inwd): +def test_symlink_dir(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "bdirlink").symlink_to("../bdir") inwd.add_and_commit() assert set(find_files("adir")) == _sep({"adir/filea", "adir/bdirlink/fileb"}) @pytest.mark.skipif(sys.platform == "win32", reason="symlinks to dir not supported") -def test_symlink_dir_source_not_in_scm(inwd): +def test_symlink_dir_source_not_in_scm(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "bdirlink").symlink_to("../bdir") assert set(find_files("adir")) == _sep({"adir/filea"}) @@ -79,7 +85,7 @@ def test_symlink_dir_source_not_in_scm(inwd): @pytest.mark.skipif( sys.platform == "win32", reason="symlinks to files not supported on windows" ) -def test_symlink_file(inwd): +def test_symlink_file(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "file1link").symlink_to("../file1") inwd.add_and_commit() assert set(find_files("adir")) == _sep( @@ -90,20 +96,20 @@ def test_symlink_file(inwd): @pytest.mark.skipif( sys.platform == "win32", reason="symlinks to files not supported on windows" ) -def test_symlink_file_source_not_in_scm(inwd): +def test_symlink_file_source_not_in_scm(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "file1link").symlink_to("../file1") assert set(find_files("adir")) == _sep({"adir/filea"}) @pytest.mark.skipif(sys.platform == "win32", reason="symlinks to dir not supported") -def test_symlink_loop(inwd): +def test_symlink_loop(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "loop").symlink_to("../adir") inwd.add_and_commit() assert set(find_files("adir")) == _sep({"adir/filea", "adir/loop"}) # -> ../adir @pytest.mark.skipif(sys.platform == "win32", reason="symlinks to dir not supported") -def test_symlink_loop_outside_path(inwd): +def test_symlink_loop_outside_path(inwd: WorkDir) -> None: (inwd.cwd / "bdir" / "loop").symlink_to("../bdir") (inwd.cwd / "adir" / "bdirlink").symlink_to("../bdir") inwd.add_and_commit() @@ -111,7 +117,7 @@ def test_symlink_loop_outside_path(inwd): @pytest.mark.skipif(sys.platform == "win32", reason="symlinks to dir not supported") -def test_symlink_dir_out_of_git(inwd): +def test_symlink_dir_out_of_git(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "outsidedirlink").symlink_to(os.path.join(__file__, "..")) inwd.add_and_commit() assert set(find_files("adir")) == _sep({"adir/filea"}) @@ -120,13 +126,21 @@ def test_symlink_dir_out_of_git(inwd): @pytest.mark.skipif( sys.platform == "win32", reason="symlinks to files not supported on windows" ) -def test_symlink_file_out_of_git(inwd): +def test_symlink_file_out_of_git(inwd: WorkDir) -> None: (inwd.cwd / "adir" / "outsidefilelink").symlink_to(__file__) inwd.add_and_commit() assert set(find_files("adir")) == _sep({"adir/filea"}) -def test_empty_root(inwd): +@pytest.mark.parametrize("path_add", ["{cwd}", "{cwd}" + os.pathsep + "broken"]) +def test_ignore_root( + inwd: WorkDir, monkeypatch: pytest.MonkeyPatch, path_add: str +) -> None: + monkeypatch.setenv("SETUPTOOLS_SCM_IGNORE_VCS_ROOTS", path_add.format(cwd=inwd.cwd)) + assert find_files() == [] + + +def test_empty_root(inwd: WorkDir) -> None: subdir = inwd.cwd / "cdir" / "subdir" subdir.mkdir(parents=True) (subdir / "filec").touch() @@ -134,7 +148,7 @@ def test_empty_root(inwd): assert set(find_files("cdir")) == _sep({"cdir/subdir/filec"}) -def test_empty_subdir(inwd): +def test_empty_subdir(inwd: WorkDir) -> None: subdir = inwd.cwd / "adir" / "emptysubdir" / "subdir" subdir.mkdir(parents=True) (subdir / "xfile").touch() @@ -145,7 +159,7 @@ def test_empty_subdir(inwd): @pytest.mark.skipif(sys.platform == "win32", reason="symlinks not supported on windows") -def test_double_include_through_symlink(inwd): +def test_double_include_through_symlink(inwd: WorkDir) -> None: (inwd.cwd / "data").mkdir() (inwd.cwd / "data" / "datafile").touch() (inwd.cwd / "adir" / "datalink").symlink_to("../data") @@ -164,7 +178,7 @@ def test_double_include_through_symlink(inwd): @pytest.mark.skipif(sys.platform == "win32", reason="symlinks not supported on windows") -def test_symlink_not_in_scm_while_target_is(inwd): +def test_symlink_not_in_scm_while_target_is(inwd: WorkDir) -> None: (inwd.cwd / "data").mkdir() (inwd.cwd / "data" / "datafile").touch() inwd.add_and_commit() @@ -180,3 +194,39 @@ def test_symlink_not_in_scm_while_target_is(inwd): "data/datafile", } ) + + +@pytest.mark.issue(587) +@pytest.mark.skip_commit() +def test_not_commited(inwd: WorkDir) -> None: + assert find_files() == [] + + +def test_unexpanded_git_archival(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: + # When substitutions in `.git_archival.txt` are not expanded, files should + # not be automatically listed. + monkeypatch.chdir(wd.cwd) + (wd.cwd / ".git_archival.txt").write_text("node: $Format:%H$", encoding="utf-8") + (wd.cwd / "file1.txt").touch() + assert find_files() == [] + + +@pytest.mark.parametrize("archive_file", [".git_archival.txt", ".hg_archival.txt"]) +def test_archive( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch, archive_file: str +) -> None: + # When substitutions in `.git_archival.txt` are not expanded, files should + # not be automatically listed. + monkeypatch.chdir(wd.cwd) + sha = "a1bda3d984d1a40d7b00ae1d0869354d6d503001" + (wd.cwd / archive_file).write_text(f"node: {sha}", encoding="utf-8") + (wd.cwd / "data").mkdir() + (wd.cwd / "data" / "datafile").touch() + + datalink = wd.cwd / "data" / "datalink" + if sys.platform != "win32": + datalink.symlink_to("data/datafile") + else: + os.link("data/datafile", datalink) + + assert set(find_files()) == _sep({archive_file, "data/datafile", "data/datalink"}) diff --git a/testing/test_functions.py b/testing/test_functions.py index 808a1d1..9a4a09b 100644 --- a/testing/test_functions.py +++ b/testing/test_functions.py @@ -1,106 +1,184 @@ -import pytest -import sys -import pkg_resources -from setuptools_scm import dump_version, get_version, PRETEND_KEY -from setuptools_scm.version import ( - guess_next_version, - meta, - format_version, - tag_to_version, -) +from __future__ import annotations -from setuptools_scm.config import Configuration -from setuptools_scm.utils import has_command +import shutil +import subprocess +from pathlib import Path -PY3 = sys.version_info > (2,) +import pytest +from setuptools_scm import Configuration +from setuptools_scm import dump_version +from setuptools_scm import get_version +from setuptools_scm._overrides import PRETEND_KEY +from setuptools_scm._run_cmd import has_command +from setuptools_scm.version import format_version +from setuptools_scm.version import guess_next_version +from setuptools_scm.version import meta +from setuptools_scm.version import tag_to_version -class MockTime(object): - def __format__(self, *k): - return "time" +c = Configuration() @pytest.mark.parametrize( - "tag, expected", + ("tag", "expected"), [ ("1.1", "1.2"), ("1.2.dev", "1.2"), ("1.1a2", "1.1a3"), - ("23.24.post2+deadbeef", "23.24.post3"), + pytest.param( + "23.24.post2+deadbeef", + "23.24.post3", + marks=pytest.mark.filterwarnings( + "ignore:.*will be stripped of its suffix.*:UserWarning" + ), + ), ], ) -def test_next_tag(tag, expected): - version = pkg_resources.parse_version(tag) +def test_next_tag(tag: str, expected: str) -> None: + version = meta(tag, config=c) assert guess_next_version(version) == expected -c = Configuration() - VERSIONS = { - "exact": meta("1.1", distance=None, dirty=False, config=c), - "zerodistance": meta("1.1", distance=0, dirty=False, config=c), - "dirty": meta("1.1", distance=None, dirty=True, config=c), - "distance": meta("1.1", distance=3, dirty=False, config=c), - "distancedirty": meta("1.1", distance=3, dirty=True, config=c), + "exact": meta("1.1", distance=0, dirty=False, config=c), + "dirty": meta("1.1", distance=0, dirty=True, config=c), + "distance-clean": meta("1.1", distance=3, dirty=False, config=c), + "distance-dirty": meta("1.1", distance=3, dirty=True, config=c), } @pytest.mark.parametrize( - "version,scheme,expected", + ("version", "version_scheme", "local_scheme", "expected"), [ - ("exact", "guess-next-dev node-and-date", "1.1"), - ("zerodistance", "guess-next-dev node-and-date", "1.2.dev0"), - ("zerodistance", "guess-next-dev no-local-version", "1.2.dev0"), - ("dirty", "guess-next-dev node-and-date", "1.2.dev0+dtime"), - ("dirty", "guess-next-dev no-local-version", "1.2.dev0"), - ("distance", "guess-next-dev node-and-date", "1.2.dev3"), - ("distancedirty", "guess-next-dev node-and-date", "1.2.dev3+dtime"), - ("distancedirty", "guess-next-dev no-local-version", "1.2.dev3"), - ("exact", "post-release node-and-date", "1.1"), - ("zerodistance", "post-release node-and-date", "1.1.post0"), - ("dirty", "post-release node-and-date", "1.1.post0+dtime"), - ("distance", "post-release node-and-date", "1.1.post3"), - ("distancedirty", "post-release node-and-date", "1.1.post3+dtime"), + ("exact", "guess-next-dev", "node-and-date", "1.1"), + ("dirty", "guess-next-dev", "node-and-date", "1.2.dev0+d20090213"), + ("dirty", "guess-next-dev", "no-local-version", "1.2.dev0"), + ("distance-clean", "guess-next-dev", "node-and-date", "1.2.dev3"), + ("distance-dirty", "guess-next-dev", "node-and-date", "1.2.dev3+d20090213"), + ("exact", "post-release", "node-and-date", "1.1"), + ("dirty", "post-release", "node-and-date", "1.1.post0+d20090213"), + ("distance-clean", "post-release", "node-and-date", "1.1.post3"), + ("distance-dirty", "post-release", "node-and-date", "1.1.post3+d20090213"), ], ) -def test_format_version(version, monkeypatch, scheme, expected): - version = VERSIONS[version] - monkeypatch.setattr(version, "time", MockTime()) - vs, ls = scheme.split() - assert format_version(version, version_scheme=vs, local_scheme=ls) == expected - - -def test_dump_version_doesnt_bail_on_value_error(tmpdir): +def test_format_version( + version: str, version_scheme: str, local_scheme: str, expected: str +) -> None: + from dataclasses import replace + + scm_version = VERSIONS[version] + configured_version = replace( + scm_version, + config=replace( + scm_version.config, version_scheme=version_scheme, local_scheme=local_scheme + ), + ) + assert format_version(configured_version) == expected + + +def test_dump_version_doesnt_bail_on_value_error(tmp_path: Path) -> None: write_to = "VERSION" version = str(VERSIONS["exact"].tag) - with pytest.raises(ValueError) as exc_info: - dump_version(tmpdir.strpath, version, write_to) - assert str(exc_info.value).startswith("bad file format:") + scm_version = meta(VERSIONS["exact"].tag, config=c) + with pytest.raises(ValueError, match="^bad file format:"): + dump_version(tmp_path, version, write_to, scm_version=scm_version) @pytest.mark.parametrize( "version", ["1.0", "1.2.3.dev1+ge871260", "1.2.3.dev15+ge871260.d20180625"] ) -def test_dump_version_works_with_pretend(version, tmpdir, monkeypatch): +def test_dump_version_works_with_pretend( + version: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(PRETEND_KEY, version) + name = "VERSION.txt" + target = tmp_path.joinpath(name) + get_version(root=tmp_path, write_to=name) + assert target.read_text() == version + + +def test_dump_version_modern(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + version = "1.2.3" monkeypatch.setenv(PRETEND_KEY, version) - get_version(write_to=str(tmpdir.join("VERSION.txt"))) - assert tmpdir.join("VERSION.txt").read() == version + name = "VERSION.txt" + + project = tmp_path.joinpath("project") + target = project.joinpath(name) + project.mkdir() + + get_version(root="..", relative_to=target, version_file=name) + assert target.read_text() == version + + +def dump_a_version(tmp_path: Path) -> None: + from setuptools_scm._integration.dump_version import write_version_to_path + + version = "1.2.3" + scm_version = meta(version, config=c) + write_version_to_path( + tmp_path / "VERSION.py", template=None, version=version, scm_version=scm_version + ) + + +def test_dump_version_on_old_python(tmp_path: Path) -> None: + python37 = shutil.which("python3.7") + if python37 is None: + pytest.skip("python3.7 not found") + dump_a_version(tmp_path) + subprocess.run( + [python37, "-c", "import VERSION;print(VERSION.version)"], + cwd=tmp_path, + check=True, + ) + + +def test_dump_version_mypy(tmp_path: Path) -> None: + mypy = shutil.which("mypy") + if mypy is None: + pytest.skip("mypy not found") + dump_a_version(tmp_path) + subprocess.run( + [mypy, "--python-version=3.8", "--strict", "VERSION.py"], + cwd=tmp_path, + check=True, + ) + + +def test_dump_version_flake8(tmp_path: Path) -> None: + flake8 = shutil.which("flake8") + if flake8 is None: + pytest.skip("flake8 not found") + dump_a_version(tmp_path) + subprocess.run([flake8, "VERSION.py"], cwd=tmp_path, check=True) + + +def test_has_command() -> None: + with pytest.warns(RuntimeWarning, match="yadayada"): + assert not has_command("yadayada_setuptools_aint_ne") -def test_has_command(recwarn): - assert not has_command("yadayada_setuptools_aint_ne") - msg = recwarn.pop() - assert "yadayada" in str(msg.message) +def test_has_command_logs_stderr(caplog: pytest.LogCaptureFixture) -> None: + """ + If the name provided to has_command() exists as a command, but gives a non-zero + return code, there should be a log message generated. + """ + with pytest.warns(RuntimeWarning, match="ls"): + has_command("ls", ["--a-flag-that-doesnt-exist-should-give-output-on-stderr"]) + found_it = False + for record in caplog.records: + if "returned non-zero. This is stderr" in record.message: + found_it = True + assert found_it, "Did not find expected log record for " @pytest.mark.parametrize( - "tag, expected_version", + ("tag", "expected_version"), [ ("1.1", "1.1"), ("release-1.1", "1.1"), pytest.param("3.3.1-rc26", "3.3.1rc26", marks=pytest.mark.issue(266)), ], ) -def test_tag_to_version(tag, expected_version): - version = str(tag_to_version(tag)) +def test_tag_to_version(tag: str, expected_version: str) -> None: + version = str(tag_to_version(tag, c)) assert version == expected_version diff --git a/testing/test_git.py b/testing/test_git.py index 542a29f..79fede3 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -1,67 +1,110 @@ -import sys +from __future__ import annotations -from setuptools_scm import integration -from setuptools_scm.utils import do, has_command -from setuptools_scm import git -import pytest +import contextlib +import os +import shutil +import subprocess +import sys +from datetime import date from datetime import datetime +from datetime import timezone from os.path import join as opj -from setuptools_scm.file_finder_git import git_find_files -import warnings +from pathlib import Path +from textwrap import dedent +from typing import Generator +from unittest.mock import Mock +from unittest.mock import patch +import pytest -skip_if_win_27 = pytest.mark.skipif( - sys.platform == "win32" and sys.version_info[0] < 3, - reason="Not supported on Windows + Python 2.7", +import setuptools_scm._file_finders +from .conftest import DebugMode +from .wd_wrapper import WorkDir +from setuptools_scm import Configuration +from setuptools_scm import git +from setuptools_scm import NonNormalizedVersion +from setuptools_scm._file_finders.git import git_find_files +from setuptools_scm._run_cmd import CommandNotFoundError +from setuptools_scm._run_cmd import CompletedProcess +from setuptools_scm._run_cmd import has_command +from setuptools_scm._run_cmd import run +from setuptools_scm.git import archival_to_version +from setuptools_scm.version import format_version + +pytestmark = pytest.mark.skipif( + not has_command("git", warn=False), reason="git executable not found" ) -with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - if not has_command("git"): - pytestmark = pytest.mark.skip(reason="git executable not found") - - -@pytest.fixture -def wd(wd, monkeypatch): +@pytest.fixture(name="wd") +def wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode) -> WorkDir: + debug_mode.disable() monkeypatch.delenv("HOME", raising=False) wd("git init") wd("git config user.email test@example.com") wd('git config user.name "a test"') wd.add_command = "git add ." wd.commit_command = "git commit -m test-{reason}" + debug_mode.enable() return wd @pytest.mark.parametrize( - "given, tag, number, node, dirty", + ("given", "tag", "number", "node", "dirty"), [ ("3.3.1-rc26-0-g9df187b", "3.3.1-rc26", 0, "g9df187b", False), ("17.33.0-rc-17-g38c3047c0", "17.33.0-rc", 17, "g38c3047c0", False), ], ) -def test_parse_describe_output(given, tag, number, node, dirty): +def test_parse_describe_output( + given: str, tag: str, number: int, node: str, dirty: bool +) -> None: parsed = git._git_parse_describe(given) assert parsed == (tag, number, node, dirty) -def test_root_relative_to(tmpdir, wd, monkeypatch): +def test_root_relative_to(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") p = wd.cwd.joinpath("sub/package") p.mkdir(parents=True) p.joinpath("setup.py").write_text( - u"""from setuptools import setup + """from setuptools import setup setup(use_scm_version={"root": "../..", "relative_to": __file__}) """ ) - res = do((sys.executable, "setup.py", "--version"), p) - assert res == "0.1.dev0" + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "0.1.dev0+d20090213" + + +def test_root_search_parent_directories( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") + p = wd.cwd.joinpath("sub/package") + p.mkdir(parents=True) + p.joinpath("setup.py").write_text( + """from setuptools import setup +setup(use_scm_version={"search_parent_directories": True}) +""" + ) + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "0.1.dev0+d20090213" + + +def test_git_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", str(wd.cwd / "not-existing")) + + wd.write("pyproject.toml", "[tool.setuptools_scm]") + with pytest.raises(CommandNotFoundError, match=r"git"): + git.parse(wd.cwd, Configuration(), git.DEFAULT_DESCRIBE) + + assert wd.get_version(fallback_version="1.0") == "1.0" @pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/298") @pytest.mark.issue(403) -def test_file_finder_no_history(wd, caplog): +def test_file_finder_no_history(wd: WorkDir, caplog: pytest.LogCaptureFixture) -> None: file_list = git_find_files(str(wd.cwd)) assert file_list == [] @@ -69,131 +112,252 @@ def test_file_finder_no_history(wd, caplog): @pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/281") -def test_parse_call_order(wd): - git.parse(str(wd.cwd), git.DEFAULT_DESCRIBE) +def test_parse_call_order(wd: WorkDir) -> None: + git.parse(str(wd.cwd), Configuration(), git.DEFAULT_DESCRIBE) + + +def sudo_devnull( + args: list[str | os.PathLike[str]], check: bool = False +) -> subprocess.CompletedProcess[bytes]: + """shortcut to run sudo with non-interactive input""" + return subprocess.run( + ["sudo", *args], + stdin=subprocess.DEVNULL, + check=check, + ) + + +@contextlib.contextmanager +def break_folder_permissions(path: Path) -> Generator[None, None, None]: + """break the permissions of a folder for a while""" + if not shutil.which("sudo"): + pytest.skip("sudo executable not found") + original_stat = path.stat() + + proc = sudo_devnull(["chown", "-R", "12345", path]) + if proc.returncode != 0: + pytest.xfail("Failed to change ownership, is passwordless sudo available?") + + try: + sudo_devnull(["chmod", "a+r", path], check=True) + sudo_devnull(["chgrp", "-R", "12345", path], check=True) + yield + finally: + # Restore the ownership + sudo_devnull(["chown", "-R", str(original_stat.st_uid), path], check=True) + sudo_devnull(["chgrp", "-R", str(original_stat.st_gid), path], check=True) -def test_version_from_git(wd): - assert wd.version == "0.1.dev0" +@pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/707") +def test_not_owner(wd: WorkDir) -> None: + with break_folder_permissions(wd.cwd): + assert git.parse(str(wd.cwd), Configuration()) + + +def test_version_from_git(wd: WorkDir) -> None: + assert wd.get_version() == "0.1.dev0+d20090213" + + parsed = git.parse(str(wd.cwd), Configuration(), git.DEFAULT_DESCRIBE) + assert parsed is not None + assert parsed.branch in ("master", "main") wd.commit_testfile() - assert wd.version.startswith("0.1.dev1+g") - assert not wd.version.endswith("1-") + assert wd.get_version().startswith("0.1.dev1+g") + assert not wd.get_version().endswith("1-") wd("git tag v0.1") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" wd.write("test.txt", "test2") - assert wd.version.startswith("0.2.dev0+g") + assert wd.get_version().startswith("0.2.dev0+g") wd.commit_testfile() - assert wd.version.startswith("0.2.dev1+g") + assert wd.get_version().startswith("0.2.dev1+g") wd("git tag version-0.2") - assert wd.version.startswith("0.2") + assert wd.get_version().startswith("0.2") wd.commit_testfile() wd("git tag version-0.2.post210+gbe48adfpost3+g0cc25f2") with pytest.warns( UserWarning, match="tag '.*' will be stripped of its suffix '.*'" ): - assert wd.version.startswith("0.2") + assert wd.get_version().startswith("0.2") wd.commit_testfile() wd("git tag 17.33.0-rc") - assert wd.version == "17.33.0rc0" + assert wd.get_version() == "17.33.0rc0" + + # custom normalization + assert wd.get_version(normalize=False) == "17.33.0-rc" + assert wd.get_version(version_cls=NonNormalizedVersion) == "17.33.0-rc" + assert ( + wd.get_version(version_cls="setuptools_scm.NonNormalizedVersion") + == "17.33.0-rc" + ) + + +setup_py_with_normalize: dict[str, str] = { + "false": """ + from setuptools import setup + setup(use_scm_version={'normalize': False, 'write_to': 'VERSION.txt'}) + """, + "with_created_class": """ + from setuptools import setup + + class MyVersion: + def __init__(self, tag_str: str): + self.version = tag_str + + def __repr__(self): + return self.version + + setup(use_scm_version={'version_cls': MyVersion, 'write_to': 'VERSION.txt'}) + """, + "with_named_import": """ + from setuptools import setup + setup(use_scm_version={ + 'version_cls': 'setuptools_scm.NonNormalizedVersion', + 'write_to': 'VERSION.txt' + }) + """, +} + + +@pytest.mark.parametrize( + "setup_py_txt", + [pytest.param(text, id=key) for key, text in setup_py_with_normalize.items()], +) +def test_git_version_unnormalized_setuptools( + setup_py_txt: str, wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + Test that when integrating with setuptools without normalization, + the version is not normalized in write_to files, + but still normalized by setuptools for the final dist metadata. + """ + # monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") + monkeypatch.chdir(wd.cwd) + wd.write("setup.py", dedent(setup_py_txt)) + + # do git operations and tag + wd.commit_testfile() + wd("git tag 17.33.0-rc1") + + # setuptools still normalizes using packaging.Version (removing the dash) + res = wd([sys.executable, "setup.py", "--version"]) + assert res == "17.33.0rc1" + + # but the version tag in the file is non-normalized (with the dash) + assert wd.cwd.joinpath("VERSION.txt").read_text() == "17.33.0-rc1" @pytest.mark.issue(179) -def test_unicode_version_scheme(wd): +def test_unicode_version_scheme(wd: WorkDir) -> None: scheme = b"guess-next-dev".decode("ascii") assert wd.get_version(version_scheme=scheme) @pytest.mark.issue(108) @pytest.mark.issue(109) -def test_git_worktree(wd): +def test_git_worktree(wd: WorkDir) -> None: wd.write("test.txt", "test2") # untracked files dont change the state - assert wd.version == "0.1.dev0" + assert wd.get_version() == "0.1.dev0+d20090213" + wd("git add test.txt") - assert wd.version.startswith("0.1.dev0+d") + assert wd.get_version().startswith("0.1.dev0+d") @pytest.mark.issue(86) -def test_git_dirty_notag(wd): +@pytest.mark.parametrize("today", [False, True]) +def test_git_dirty_notag( + today: bool, wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + if today: + monkeypatch.delenv("SOURCE_DATE_EPOCH", raising=False) wd.commit_testfile() wd.write("test.txt", "test2") wd("git add test.txt") - assert wd.version.startswith("0.1.dev1") - # the date on the tag is in UTC - today = datetime.utcnow().date() - # we are dirty, check for the tag - assert today.strftime(".d%Y%m%d") in wd.version + version = wd.get_version() + + if today: + # the date on the tag is in UTC + tag = datetime.now(timezone.utc).date().strftime(".d%Y%m%d") + else: + tag = ".d20090213" + assert version.startswith("0.1.dev1+g") + assert version.endswith(tag) @pytest.mark.issue(193) -def test_git_worktree_support(wd, tmpdir): +@pytest.mark.xfail(reason="sometimes relative path results") +def test_git_worktree_support(wd: WorkDir, tmp_path: Path) -> None: wd.commit_testfile() - worktree = tmpdir.join("work_tree") + worktree = tmp_path / "work_tree" wd("git worktree add -b work-tree %s" % worktree) - res = do([sys.executable, "-m", "setuptools_scm", "ls"], cwd=worktree) - assert str(worktree) in res + res = run([sys.executable, "-m", "setuptools_scm", "ls"], cwd=worktree) + assert "test.txt" in res.stdout + assert str(worktree) in res.stdout -@pytest.fixture -def shallow_wd(wd, tmpdir): +@pytest.fixture() +def shallow_wd(wd: WorkDir, tmp_path: Path) -> Path: wd.commit_testfile() wd.commit_testfile() wd.commit_testfile() - target = tmpdir.join("wd_shallow") - do(["git", "clone", "file://%s" % wd.cwd, str(target), "--depth=1"]) + target = tmp_path / "wd_shallow" + run(["git", "clone", f"file://{wd.cwd}", target, "--depth=1"], tmp_path, check=True) return target -def test_git_parse_shallow_warns(shallow_wd, recwarn): - git.parse(str(shallow_wd)) +def test_git_parse_shallow_warns( + shallow_wd: Path, recwarn: pytest.WarningsRecorder +) -> None: + git.parse(shallow_wd, Configuration()) + print(list(recwarn)) msg = recwarn.pop() assert "is shallow and may cause errors" in str(msg.message) -def test_git_parse_shallow_fail(shallow_wd): - with pytest.raises(ValueError) as einfo: - git.parse(str(shallow_wd), pre_parse=git.fail_on_shallow) - - assert "git fetch" in str(einfo.value) +def test_git_parse_shallow_fail(shallow_wd: Path) -> None: + with pytest.raises(ValueError, match="git fetch"): + git.parse(str(shallow_wd), Configuration(), pre_parse=git.fail_on_shallow) -def test_git_shallow_autocorrect(shallow_wd, recwarn): - git.parse(str(shallow_wd), pre_parse=git.fetch_on_shallow) +def test_git_shallow_autocorrect( + shallow_wd: Path, recwarn: pytest.WarningsRecorder +) -> None: + git.parse(str(shallow_wd), Configuration(), pre_parse=git.fetch_on_shallow) msg = recwarn.pop() assert "git fetch was used to rectify" in str(msg.message) - git.parse(str(shallow_wd), pre_parse=git.fail_on_shallow) + git.parse(str(shallow_wd), Configuration(), pre_parse=git.fail_on_shallow) -def test_find_files_stop_at_root_git(wd): +def test_find_files_stop_at_root_git(wd: WorkDir) -> None: wd.commit_testfile() project = wd.cwd / "project" project.mkdir() project.joinpath("setup.cfg").touch() - assert integration.find_files(str(project)) == [] + assert setuptools_scm._file_finders.find_files(str(project)) == [] @pytest.mark.issue(128) -def test_parse_no_worktree(tmpdir): - ret = git.parse(str(tmpdir)) +def test_parse_no_worktree(tmp_path: Path) -> None: + ret = git.parse(str(tmp_path), Configuration(root=str(tmp_path))) assert ret is None -def test_alphanumeric_tags_match(wd): +def test_alphanumeric_tags_match(wd: WorkDir) -> None: wd.commit_testfile() wd("git tag newstyle-development-started") - assert wd.version.startswith("0.1.dev1+g") + assert wd.get_version().startswith("0.1.dev1+g") -@skip_if_win_27 -def test_git_archive_export_ignore(wd, monkeypatch): +def test_git_archive_export_ignore( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: wd.write("test1.txt", "test") wd.write("test2.txt", "test") wd.write( @@ -205,32 +369,42 @@ def test_git_archive_export_ignore(wd, monkeypatch): wd("git add test1.txt test2.txt") wd.commit() monkeypatch.chdir(wd.cwd) - assert integration.find_files(".") == [opj(".", "test1.txt")] + assert setuptools_scm._file_finders.find_files(".") == [opj(".", "test1.txt")] -@skip_if_win_27 @pytest.mark.issue(228) -def test_git_archive_subdirectory(wd, monkeypatch): - wd("mkdir foobar") +def test_git_archive_subdirectory(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: + os.mkdir(wd.cwd / "foobar") wd.write("foobar/test1.txt", "test") wd("git add foobar") wd.commit() monkeypatch.chdir(wd.cwd) - assert integration.find_files(".") == [opj(".", "foobar", "test1.txt")] + assert setuptools_scm._file_finders.find_files(".") == [ + opj(".", "foobar", "test1.txt") + ] -@skip_if_win_27 @pytest.mark.issue(251) -def test_git_archive_run_from_subdirectory(wd, monkeypatch): - wd("mkdir foobar") +def test_git_archive_run_from_subdirectory( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: + os.mkdir(wd.cwd / "foobar") wd.write("foobar/test1.txt", "test") wd("git add foobar") wd.commit() monkeypatch.chdir(wd.cwd / "foobar") - assert integration.find_files(".") == [opj(".", "test1.txt")] + assert setuptools_scm._file_finders.find_files(".") == [opj(".", "test1.txt")] -def test_git_feature_branch_increments_major(wd): +@pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/728") +def test_git_branch_names_correct(wd: WorkDir) -> None: + wd.commit_testfile() + wd("git checkout -b test/fun") + wd_git = git.GitWorkdir(wd.cwd) + assert wd_git.get_branch() == "test/fun" + + +def test_git_feature_branch_increments_major(wd: WorkDir) -> None: wd.commit_testfile() wd("git tag 1.0.0") wd.commit_testfile() @@ -241,7 +415,7 @@ def test_git_feature_branch_increments_major(wd): @pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/303") -def test_not_matching_tags(wd): +def test_not_matching_tags(wd: WorkDir) -> None: wd.commit_testfile() wd("git tag apache-arrow-0.11.1") wd.commit_testfile() @@ -254,20 +428,147 @@ def test_not_matching_tags(wd): @pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/411") -@pytest.mark.xfail(reason="https://github.com/pypa/setuptools_scm/issues/449") -def test_non_dotted_version(wd): +def test_non_dotted_version(wd: WorkDir) -> None: wd.commit_testfile() wd("git tag apache-arrow-1") wd.commit_testfile() assert wd.get_version().startswith("2") +def test_non_dotted_version_with_updated_regex(wd: WorkDir) -> None: + wd.commit_testfile() + wd("git tag apache-arrow-1") + wd.commit_testfile() + assert wd.get_version(tag_regex=r"^apache-arrow-([\.0-9]+)$").startswith("2") + + +def test_non_dotted_tag_no_version_match(wd: WorkDir) -> None: + wd.commit_testfile() + wd("git tag apache-arrow-0.11.1") + wd.commit_testfile() + wd("git tag apache-arrow") + wd.commit_testfile() + assert wd.get_version().startswith("0.11.2.dev2") + + @pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/381") -def test_gitdir(monkeypatch, wd): - """ - """ +def test_gitdir(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None: + """ """ wd.commit_testfile() - normal = wd.version + normal = wd.get_version() # git hooks set this and break subsequent setuptools_scm unless we clean monkeypatch.setenv("GIT_DIR", __file__) - assert wd.version == normal + assert wd.get_version() == normal + + +def test_git_getdate(wd: WorkDir) -> None: + # TODO: case coverage for git wd parse + today = datetime.now(timezone.utc).date() + + def parse_date() -> date: + parsed = git.parse(os.fspath(wd.cwd), Configuration()) + assert parsed is not None + assert parsed.node_date is not None + return parsed.node_date + + git_wd = git.GitWorkdir(wd.cwd) + assert git_wd.get_head_date() is None + assert parse_date() == today + + wd.commit_testfile() + assert git_wd.get_head_date() == today + assert parse_date() == today + + +def test_git_getdate_badgit( + wd: WorkDir, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + wd.commit_testfile() + git_wd = git.GitWorkdir(wd.cwd) + fake_date_result = CompletedProcess(args=[], stdout="%cI", stderr="", returncode=0) + with patch.object( + git, + "run_git", + Mock(return_value=fake_date_result), + ): + assert git_wd.get_head_date() is None + + +@pytest.fixture() +def signed_commit_wd(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> WorkDir: + if not has_command("gpg", args=["--version"], warn=False): + pytest.skip("gpg executable not found") + + wd.write( + ".gpg_batch_params", + """\ +%no-protection +%transient-key +Key-Type: RSA +Key-Length: 2048 +Name-Real: a test +Name-Email: test@example.com +Expire-Date: 0 +""", + ) + monkeypatch.setenv("GNUPGHOME", str(wd.cwd.resolve(strict=True))) + wd("gpg --batch --generate-key .gpg_batch_params") + + wd("git config log.showSignature true") + wd.signed_commit_command = "git commit -S -m test-{reason}" + return wd + + +@pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/548") +def test_git_getdate_signed_commit(signed_commit_wd: WorkDir) -> None: + today = datetime.now(timezone.utc).date() + signed_commit_wd.commit_testfile(signed=True) + git_wd = git.GitWorkdir(signed_commit_wd.cwd) + assert git_wd.get_head_date() == today + + +@pytest.mark.parametrize( + ("expected", "from_data"), + [ + ( + "1.0", + {"describe-name": "1.0-0-g0000"}, + ), + ( + "1.1.dev3+g0000", + { + "describe-name": "1.0-3-g0000", + "node": "0" * 20, + }, + ), + ("0.0", {"node": "0" * 20}), + ("1.2.2", {"describe-name": "release-1.2.2-0-g00000"}), + ("1.2.2.dev0", {"ref-names": "tag: release-1.2.2.dev"}), + ("1.2.2", {"describe-name": "v1.2.2"}), + ], +) +@pytest.mark.filterwarnings("ignore:git archive did not support describe output") +def test_git_archival_to_version(expected: str, from_data: dict[str, str]) -> None: + config = Configuration( + version_scheme="guess-next-dev", local_scheme="node-and-date" + ) + version = archival_to_version(from_data, config=config) + assert version is not None + assert format_version(version) == expected + + +@pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/727") +def test_git_archival_node_missing_no_version() -> None: + config = Configuration() + version = archival_to_version({}, config=config) + assert version is None + + +def test_git_archival_from_unfiltered() -> None: + config = Configuration() + + with pytest.warns( + UserWarning, match=r"unprocessed git archival found \(no export subst applied\)" + ): + version = archival_to_version({"node": "$Format:%H$"}, config=config) + assert version is None diff --git a/testing/test_hg_git.py b/testing/test_hg_git.py new file mode 100644 index 0000000..9527cb0 --- /dev/null +++ b/testing/test_hg_git.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pytest + +from setuptools_scm._run_cmd import has_command +from setuptools_scm._run_cmd import run +from testing.wd_wrapper import WorkDir + + +@pytest.fixture(scope="module", autouse=True) +def _check_hg_git() -> None: + if not has_command("hg", warn=False): + pytest.skip("hg executable not found") + + res = run("hg debuginstall --template {pythonexe}", cwd=".") + + if res.returncode: + skip_no_hggit = True + else: + res = run([res.stdout, "-c", "import hggit"], cwd=".") + skip_no_hggit = bool(res.returncode) + if skip_no_hggit: + pytest.skip("hg-git not installed") + + +def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None: + wd, wd_git = repositories_hg_git + + assert wd_git.get_version() == "0.1.dev0+d20090213" + assert wd.get_version() == "0.1.dev0+d20090213" + + wd_git.commit_testfile() + version_git = wd_git.get_version() + + wd("hg pull -u") + + version = wd.get_version() + + assert version_git.startswith("0.1.dev1+g") + assert version.startswith("0.1.dev1+g") + + assert not version_git.endswith("1-") + assert not version.endswith("1-") + + wd_git("git tag v0.1") + wd("hg pull -u") + assert wd_git.get_version() == "0.1" + assert wd.get_version() == "0.1" + + wd_git.write("test.txt", "test2") + wd.write("test.txt", "test2") + assert wd_git.get_version().startswith("0.2.dev0+g") + assert wd.get_version().startswith("0.2.dev0+g") + + wd_git.commit_testfile() + wd("hg pull") + wd("hg up -C") + assert wd_git.get_version().startswith("0.2.dev1+g") + assert wd.get_version().startswith("0.2.dev1+g") + + wd_git("git tag version-0.2") + wd("hg pull -u") + assert wd_git.get_version().startswith("0.2") + assert wd.get_version().startswith("0.2") + + wd_git.commit_testfile() + wd_git("git tag version-0.2.post210+gbe48adfpost3+g0cc25f2") + wd("hg pull -u") + with pytest.warns( + UserWarning, match="tag '.*' will be stripped of its suffix '.*'" + ): + assert wd_git.get_version().startswith("0.2") + + with pytest.warns( + UserWarning, match="tag '.*' will be stripped of its suffix '.*'" + ): + assert wd.get_version().startswith("0.2") + + wd_git.commit_testfile() + wd_git("git tag 17.33.0-rc") + wd("hg pull -u") + assert wd_git.get_version() == "17.33.0rc0" + assert wd.get_version() == "17.33.0rc0" diff --git a/testing/test_integration.py b/testing/test_integration.py index 68c3bfe..45ee295 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -1,17 +1,28 @@ +from __future__ import annotations + +import importlib.metadata +import os +import subprocess import sys +import textwrap +from pathlib import Path import pytest -from setuptools_scm.utils import do +import setuptools_scm._integration.setuptools +from .wd_wrapper import WorkDir +from setuptools_scm import Configuration +from setuptools_scm._integration.setuptools import _warn_on_old_setuptools +from setuptools_scm._overrides import PRETEND_KEY +from setuptools_scm._overrides import PRETEND_KEY_NAMED +from setuptools_scm._run_cmd import run +c = Configuration() -@pytest.fixture -def wd(wd): - try: - wd("git init") - except OSError: - pytest.skip("git executable not found") +@pytest.fixture() +def wd(wd: WorkDir) -> WorkDir: + wd("git init") wd("git config user.email test@example.com") wd('git config user.name "a test"') wd.add_command = "git add ." @@ -19,24 +30,223 @@ def wd(wd): return wd -def test_pyproject_support(tmpdir, monkeypatch): - pytest.importorskip("toml") +def test_pyproject_support(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + if sys.version_info <= (3, 10): + pytest.importorskip("tomli") monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") - pkg = tmpdir.ensure("package", dir=42) - pkg.join("pyproject.toml").write( - """[tool.setuptools_scm] -fallback_version = "12.34" -""" + pkg = tmp_path / "package" + pkg.mkdir() + pkg.joinpath("pyproject.toml").write_text( + textwrap.dedent( + """ + [tool.setuptools_scm] + fallback_version = "12.34" + [project] + name = "foo" + description = "Factory ⸻ A code generator 🏭" + authors = [{name = "Łukasz Langa"}] + dynamic = ["version"] + """ + ), + encoding="utf-8", ) - pkg.join("setup.py").write("__import__('setuptools').setup()") - res = do((sys.executable, "setup.py", "--version"), pkg) - assert res == "12.34" + pkg.joinpath("setup.py").write_text("__import__('setuptools').setup()") + res = run([sys.executable, "setup.py", "--version"], pkg) + assert res.stdout == "12.34" -def test_pyproject_support_with_git(tmpdir, monkeypatch, wd): - monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") - pkg = tmpdir.join("wd") - pkg.join("pyproject.toml").write("""[tool.setuptools_scm]""") - pkg.join("setup.py").write("__import__('setuptools').setup()") - res = do((sys.executable, "setup.py", "--version"), pkg) - assert res == "0.1.dev0" +PYPROJECT_FILES = { + "setup.py": "[tool.setuptools_scm]", + "setup.cfg": "[tool.setuptools_scm]", + "pyproject tool.setuptools_scm": ( + "[tool.setuptools_scm]\ndist_name='setuptools_scm_example'" + ), + "pyproject.project": ( + "[project]\nname='setuptools_scm_example'\n" + "dynamic=['version']\n[tool.setuptools_scm]" + ), +} + +SETUP_PY_PLAIN = "__import__('setuptools').setup()" +SETUP_PY_WITH_NAME = "__import__('setuptools').setup(name='setuptools_scm_example')" + +SETUP_PY_FILES = { + "setup.py": SETUP_PY_WITH_NAME, + "setup.cfg": SETUP_PY_PLAIN, + "pyproject tool.setuptools_scm": SETUP_PY_PLAIN, + "pyproject.project": SETUP_PY_PLAIN, +} + +SETUP_CFG_FILES = { + "setup.py": "", + "setup.cfg": "[metadata]\nname=setuptools_scm_example", + "pyproject tool.setuptools_scm": "", + "pyproject.project": "", +} + +with_metadata_in = pytest.mark.parametrize( + "metadata_in", + ["setup.py", "setup.cfg", "pyproject tool.setuptools_scm", "pyproject.project"], +) + + +@with_metadata_in +def test_pyproject_support_with_git(wd: WorkDir, metadata_in: str) -> None: + if sys.version_info <= (3, 10): + pytest.importorskip("tomli") + wd.write("pyproject.toml", PYPROJECT_FILES[metadata_in]) + wd.write("setup.py", SETUP_PY_FILES[metadata_in]) + wd.write("setup.cfg", SETUP_CFG_FILES[metadata_in]) + res = wd([sys.executable, "setup.py", "--version"]) + assert res.endswith("0.1.dev0+d20090213") + + +@pytest.mark.parametrize("use_scm_version", ["True", "{}", "lambda: {}"]) +def test_pyproject_missing_setup_hook_works(wd: WorkDir, use_scm_version: str) -> None: + wd.write( + "setup.py", + f"""__import__('setuptools').setup( + name="example-scm-unique", + use_scm_version={use_scm_version}, + )""", + ) + wd.write( + "pyproject.toml", + textwrap.dedent( + """ + [build-system] + requires=["setuptools", "setuptools_scm"] + build-backend = "setuptools.build_meta" + [tool] + """ + ), + ) + + res = subprocess.run( + [sys.executable, "setup.py", "--version"], + cwd=wd.cwd, + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + stripped = res.stdout.strip() + assert stripped.endswith("0.1.dev0+d20090213") + + res_build = subprocess.run( + [sys.executable, "-m", "build", "-nxw"], + env={k: v for k, v in os.environ.items() if k != "SETUPTOOLS_SCM_DEBUG"}, + cwd=wd.cwd, + ) + import pprint + + pprint.pprint(res_build) + wheel: Path = next(wd.cwd.joinpath("dist").iterdir()) + assert "0.1.dev0+d20090213" in str(wheel) + + +def test_pretend_version(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None: + monkeypatch.setenv(PRETEND_KEY, "1.0.0") + + assert wd.get_version() == "1.0.0" + assert wd.get_version(dist_name="ignored") == "1.0.0" + + +@with_metadata_in +def test_pretend_version_named_pyproject_integration( + monkeypatch: pytest.MonkeyPatch, wd: WorkDir, metadata_in: str +) -> None: + test_pyproject_support_with_git(wd, metadata_in) + monkeypatch.setenv( + PRETEND_KEY_NAMED.format(name="setuptools_scm_example".upper()), "3.2.1" + ) + res = wd([sys.executable, "setup.py", "--version"]) + assert res.endswith("3.2.1") + + +def test_pretend_version_named(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None: + monkeypatch.setenv(PRETEND_KEY_NAMED.format(name="test".upper()), "1.0.0") + monkeypatch.setenv(PRETEND_KEY_NAMED.format(name="test2".upper()), "2.0.0") + assert wd.get_version(dist_name="test") == "1.0.0" + assert wd.get_version(dist_name="test2") == "2.0.0" + + +def test_pretend_version_name_takes_precedence( + monkeypatch: pytest.MonkeyPatch, wd: WorkDir +) -> None: + monkeypatch.setenv(PRETEND_KEY_NAMED.format(name="test".upper()), "1.0.0") + monkeypatch.setenv(PRETEND_KEY, "2.0.0") + assert wd.get_version(dist_name="test") == "1.0.0" + + +def test_pretend_version_accepts_bad_string( + monkeypatch: pytest.MonkeyPatch, wd: WorkDir +) -> None: + monkeypatch.setenv(PRETEND_KEY, "dummy") + wd.write("setup.py", SETUP_PY_PLAIN) + assert wd.get_version(write_to="test.py") == "dummy" + pyver = wd([sys.executable, "setup.py", "--version"]) + assert pyver == "0.0.0" + + +def testwarn_on_broken_setuptools() -> None: + _warn_on_old_setuptools("61") + with pytest.warns(RuntimeWarning, match="ERROR: setuptools==60"): + _warn_on_old_setuptools("60") + + +@pytest.mark.issue(611) +def test_distribution_provides_extras() -> None: + from importlib.metadata import distribution + + dist = distribution("setuptools_scm") + pe: list[str] = dist.metadata.get_all("Provides-Extra", []) + assert sorted(pe) == ["docs", "rich", "test", "toml"] + + +@pytest.mark.issue(760) +def test_unicode_in_setup_cfg(tmp_path: Path) -> None: + cfg = tmp_path / "setup.cfg" + cfg.write_text( + textwrap.dedent( + """ + [metadata] + name = configparser + author = Łukasz Langa + """ + ), + encoding="utf-8", + ) + name = setuptools_scm._integration.setuptools.read_dist_name_from_setup_cfg(cfg) + assert name == "configparser" + + +def test_setuptools_version_keyword_ensures_regex( + wd: WorkDir, + monkeypatch: pytest.MonkeyPatch, +) -> None: + wd.commit_testfile("test") + wd("git tag 1.0") + monkeypatch.chdir(wd.cwd) + from setuptools_scm._integration.setuptools import version_keyword + import setuptools + + dist = setuptools.Distribution({"name": "test"}) + version_keyword(dist, "use_scm_version", {"tag_regex": "(1.0)"}) + + +@pytest.mark.parametrize( + "ep_name", ["setuptools_scm.parse_scm", "setuptools_scm.parse_scm_fallback"] +) +def test_git_archival_plugin_ignored(tmp_path: Path, ep_name: str) -> None: + tmp_path.joinpath(".git_archival.txt").write_text("broken") + try: + dist = importlib.metadata.distribution("setuptools_scm_git_archive") + except importlib.metadata.PackageNotFoundError: + pytest.skip("setuptools_scm_git_archive not installed") + else: + print(dist.metadata["Name"], dist.version) + from setuptools_scm.discover import iter_matching_entrypoints + + found = list(iter_matching_entrypoints(tmp_path, config=c, entrypoint=ep_name)) + imports = [item.value for item in found] + assert "setuptools_scm_git_archive:parse" not in imports diff --git a/testing/test_internal_log_level.py b/testing/test_internal_log_level.py new file mode 100644 index 0000000..68ce8e0 --- /dev/null +++ b/testing/test_internal_log_level.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import logging + +from setuptools_scm import _log + + +def test_log_levels_when_set() -> None: + assert _log._default_log_level({"SETUPTOOLS_SCM_DEBUG": ""}) == logging.DEBUG + assert _log._default_log_level({"SETUPTOOLS_SCM_DEBUG": "INFO"}) == logging.DEBUG + assert _log._default_log_level({"SETUPTOOLS_SCM_DEBUG": "3"}) == logging.DEBUG + + +def test_log_levels_when_unset() -> None: + assert _log._default_log_level({}) == logging.WARNING diff --git a/testing/test_main.py b/testing/test_main.py index 97ea05e..cf760a6 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,10 +1,67 @@ +from __future__ import annotations + import os.path +import sys +import textwrap + +import pytest +from .wd_wrapper import WorkDir -def test_main(): + +def test_main() -> None: mainfile = os.path.join( os.path.dirname(__file__), "..", "src", "setuptools_scm", "__main__.py" ) + ns = {"__package__": "setuptools_scm"} with open(mainfile) as f: code = compile(f.read(), "__main__.py", "exec") - exec(code) + exec(code, ns) + + +@pytest.fixture() +def repo(wd: WorkDir) -> WorkDir: + wd("git init") + wd("git config user.email user@host") + wd("git config user.name user") + wd.add_command = "git add ." + wd.commit_command = "git commit -m test-{reason}" + + wd.write("README.rst", "My example") + wd.add_and_commit() + wd("git tag v0.1.0") + + wd.write("file.txt", "file.txt") + wd.add_and_commit() + + return wd + + +def test_repo_with_config(repo: WorkDir) -> None: + pyproject = """\ + [tool.setuptools_scm] + version_scheme = "no-guess-dev" + + [project] + name = "example" + """ + repo.write("pyproject.toml", textwrap.dedent(pyproject)) + repo.add_and_commit() + res = repo([sys.executable, "-m", "setuptools_scm"]) + assert res.startswith("0.1.0.post1.dev2") + + +def test_repo_without_config(repo: WorkDir) -> None: + res = repo([sys.executable, "-m", "setuptools_scm"]) + assert res.startswith("0.1.1.dev1") + + +def test_repo_with_pyproject_missing_setuptools_scm(repo: WorkDir) -> None: + pyproject = """\ + [project] + name = "example" + """ + repo.write("pyproject.toml", textwrap.dedent(pyproject)) + repo.add_and_commit() + res = repo([sys.executable, "-m", "setuptools_scm"]) + assert res.startswith("0.1.1.dev2") diff --git a/testing/test_mercurial.py b/testing/test_mercurial.py index 29370c1..3aa0097 100644 --- a/testing/test_mercurial.py +++ b/testing/test_mercurial.py @@ -1,20 +1,27 @@ -from setuptools_scm import format_version -from setuptools_scm.hg import archival_to_version, parse -from setuptools_scm import integration -from setuptools_scm.config import Configuration -from setuptools_scm.utils import has_command +from __future__ import annotations + +import os +from pathlib import Path + import pytest -import warnings +import setuptools_scm._file_finders +from setuptools_scm import Configuration +from setuptools_scm._run_cmd import CommandNotFoundError +from setuptools_scm._run_cmd import has_command +from setuptools_scm.hg import archival_to_version +from setuptools_scm.hg import parse +from setuptools_scm.version import format_version +from testing.wd_wrapper import WorkDir -with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - if not has_command("hg"): - pytestmark = pytest.mark.skip(reason="hg executable not found") +pytestmark = pytest.mark.skipif( + not has_command("hg", warn=False), reason="hg executable not found" +) -@pytest.fixture -def wd(wd): + +@pytest.fixture() +def wd(wd: WorkDir) -> WorkDir: wd("hg init") wd.add_command = "hg add ." wd.commit_command = 'hg commit -m test-{reason} -u test -d "0 0"' @@ -34,101 +41,112 @@ archival_mapping = { } -@pytest.mark.parametrize("expected,data", sorted(archival_mapping.items())) -def test_archival_to_version(expected, data): - config = Configuration() - version = archival_to_version(data, config=config) - assert ( - format_version( - version, version_scheme="guess-next-dev", local_scheme="node-and-date" - ) - == expected +@pytest.mark.parametrize(("expected", "data"), sorted(archival_mapping.items())) +def test_archival_to_version(expected: str, data: dict[str, str]) -> None: + config = Configuration( + version_scheme="guess-next-dev", local_scheme="node-and-date" ) + version = archival_to_version(data, config=config) + assert format_version(version) == expected -def test_find_files_stop_at_root_hg(wd, monkeypatch): +def test_hg_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", str(wd.cwd / "not-existing")) + config = Configuration() + wd.write("pyproject.toml", "[tool.setuptools_scm]") + with pytest.raises(CommandNotFoundError, match=r"hg"): + parse(wd.cwd, config=config) + + assert wd.get_version(fallback_version="1.0") == "1.0" + + +def test_find_files_stop_at_root_hg( + wd: WorkDir, monkeypatch: pytest.MonkeyPatch +) -> None: wd.commit_testfile() project = wd.cwd / "project" project.mkdir() project.joinpath("setup.cfg").touch() # setup.cfg has not been committed - assert integration.find_files(str(project)) == [] + assert setuptools_scm._file_finders.find_files(str(project)) == [] # issue 251 wd.add_and_commit() monkeypatch.chdir(project) - assert integration.find_files() == ["setup.cfg"] + assert setuptools_scm._file_finders.find_files() == ["setup.cfg"] # XXX: better tests for tag prefixes -def test_version_from_hg_id(wd): - assert wd.version == "0.0" +def test_version_from_hg_id(wd: WorkDir) -> None: + assert wd.get_version() == "0.0" wd.commit_testfile() - assert wd.version.startswith("0.1.dev2+") + assert wd.get_version().startswith("0.1.dev1+") # tagging commit is considered the tag wd('hg tag v0.1 -u test -d "0 0"') - assert wd.version == "0.1" + assert wd.get_version() == "0.1" wd.commit_testfile() - assert wd.version.startswith("0.2.dev2") + assert wd.get_version().startswith("0.2.dev2") wd("hg up v0.1") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" - # commit originating from the taged revision - # that is not a actual tag + # commit originating from the tagged revision + # that is not an actual tag wd.commit_testfile() - assert wd.version.startswith("0.2.dev1+") + assert wd.get_version().startswith("0.2.dev1+") # several tags wd("hg up") wd('hg tag v0.2 -u test -d "0 0"') wd('hg tag v0.3 -u test -d "0 0" -r v0.2') - assert wd.version == "0.3" + assert wd.get_version() == "0.3" -def test_version_from_archival(wd): +def test_version_from_archival(wd: WorkDir) -> None: # entrypoints are unordered, - # cleaning the wd ensure this test wont break randomly + # cleaning the wd ensure this test won't break randomly wd.cwd.joinpath(".hg").rename(wd.cwd / ".nothg") wd.write(".hg_archival.txt", "node: 000000000000\n" "tag: 0.1\n") - assert wd.version == "0.1" + assert wd.get_version() == "0.1" wd.write( ".hg_archival.txt", "node: 000000000000\n" "latesttag: 0.1\n" "latesttagdistance: 3\n", ) - assert wd.version == "0.2.dev3+h000000000000" + assert wd.get_version() == "0.2.dev3+h000000000000" @pytest.mark.issue("#72") -def test_version_in_merge(wd): +def test_version_in_merge(wd: WorkDir) -> None: wd.commit_testfile() wd.commit_testfile() wd("hg up 0") wd.commit_testfile() wd("hg merge --tool :merge") - assert wd.version is not None + assert wd.get_version() is not None @pytest.mark.issue(128) -def test_parse_no_worktree(tmpdir): - ret = parse(str(tmpdir)) +def test_parse_no_worktree(tmp_path: Path) -> None: + config = Configuration() + ret = parse(os.fspath(tmp_path), config) assert ret is None -@pytest.fixture -def version_1_0(wd): +@pytest.fixture() +def version_1_0(wd: WorkDir) -> WorkDir: wd("hg branch default") wd.commit_testfile() wd('hg tag 1.0.0 -u test -d "0 0"') return wd -@pytest.fixture -def pre_merge_commit_after_tag(wd, version_1_0): +@pytest.fixture() +def pre_merge_commit_after_tag(version_1_0: WorkDir) -> WorkDir: + wd = version_1_0 wd("hg branch testbranch") wd.write("branchfile", "branchtext") wd(wd.add_command) @@ -139,43 +157,41 @@ def pre_merge_commit_after_tag(wd, version_1_0): @pytest.mark.usefixtures("pre_merge_commit_after_tag") -def test_version_bump_before_merge_commit(wd): - assert wd.version.startswith("1.0.1.dev1+") +def test_version_bump_before_merge_commit(wd: WorkDir) -> None: + assert wd.get_version().startswith("1.0.1.dev1+") @pytest.mark.issue(219) @pytest.mark.usefixtures("pre_merge_commit_after_tag") -def test_version_bump_from_merge_commit(wd): +def test_version_bump_from_merge_commit(wd: WorkDir) -> None: wd.commit() - assert wd.version.startswith("1.0.1.dev3+") # issue 219 + assert wd.get_version().startswith("1.0.1.dev3+") # issue 219 @pytest.mark.usefixtures("version_1_0") -def test_version_bump_from_commit_including_hgtag_mods(wd): - """ Test the case where a commit includes changes to .hgtags and other files - """ +def test_version_bump_from_commit_including_hgtag_mods(wd: WorkDir) -> None: + """Test the case where a commit includes changes to .hgtags and other files""" with wd.cwd.joinpath(".hgtags").open("ab") as tagfile: tagfile.write(b"0 0\n") wd.write("branchfile", "branchtext") wd(wd.add_command) - assert wd.version.startswith("1.0.1.dev1+") # bump from dirty version + assert wd.get_version().startswith("1.0.1.dev1+") # bump from dirty version wd.commit() # commits both the testfile _and_ .hgtags - assert wd.version.startswith("1.0.1.dev2+") + assert wd.get_version().startswith("1.0.1.dev2+") @pytest.mark.issue(229) @pytest.mark.usefixtures("version_1_0") -def test_latest_tag_detection(wd): - """ Tests that tags not containing a "." are ignored, the same as for git. - Note that will be superceded by the fix for pypa/setuptools_scm/issues/235 +def test_latest_tag_detection(wd: WorkDir) -> None: + """Tests that tags not containing a "." are ignored, the same as for git. + Note that will be superseded by the fix for pypa/setuptools_scm/issues/235 """ wd('hg tag some-random-tag -u test -d "0 0"') - assert wd.version == "1.0.0" + assert wd.get_version() == "1.0.0" @pytest.mark.usefixtures("version_1_0") -def test_feature_branch_increments_major(wd): - +def test_feature_branch_increments_major(wd: WorkDir) -> None: wd.commit_testfile() assert wd.get_version(version_scheme="python-simplified-semver").startswith("1.0.1") wd("hg branch feature/fun") diff --git a/testing/test_regressions.py b/testing/test_regressions.py index 8bde373..e2594e8 100644 --- a/testing/test_regressions.py +++ b/testing/test_regressions.py @@ -1,70 +1,74 @@ -import sys +from __future__ import annotations + +import pprint import subprocess +import sys +from dataclasses import replace +from importlib.metadata import distribution +from importlib.metadata import EntryPoint +from pathlib import Path + +import pytest -from setuptools_scm import get_version +from setuptools_scm import Configuration +from setuptools_scm._run_cmd import run from setuptools_scm.git import parse -from setuptools_scm.utils import do_ex, do +from setuptools_scm.integration import data_from_mime +from setuptools_scm.version import meta -import pytest + +def test_data_from_mime_ignores_body() -> None: + assert data_from_mime( + "test", + "version: 1.0\r\n\r\nversion: bad", + ) == {"version": "1.0"} -def test_pkginfo_noscmroot(tmpdir, monkeypatch): +def test_pkginfo_noscmroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """if we are indeed a sdist, the root does not apply""" monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") # we should get the version from pkg-info if git is broken - p = tmpdir.ensure("sub/package", dir=1) - tmpdir.mkdir(".git") - p.join("setup.py").write( + p = tmp_path.joinpath("sub/package") + p.mkdir(parents=True) + + tmp_path.joinpath(".git").mkdir() + p.joinpath("setup.py").write_text( "from setuptools import setup;" 'setup(use_scm_version={"root": ".."})' ) - _, stderr, ret = do_ex((sys.executable, "setup.py", "--version"), p) - assert "setuptools-scm was unable to detect version for" in stderr - assert ret == 1 + res = run([sys.executable, "setup.py", "--version"], p) + assert "setuptools-scm was unable to detect version for" in res.stderr + assert res.returncode == 1 - p.join("PKG-INFO").write("Version: 1.0") - res = do((sys.executable, "setup.py", "--version"), p) - assert res == "1.0" + p.joinpath("PKG-INFO").write_text("Version: 1.0") + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "1.0" try: - do("git init", p.dirpath()) + run("git init", p.parent) except OSError: pass else: - res = do((sys.executable, "setup.py", "--version"), p) - assert res == "0.1.dev0" - - -def test_pip_egg_info(tmpdir, monkeypatch): - """if we are indeed a sdist, the root does not apply""" - - # we should get the version from pkg-info if git is broken - p = tmpdir.ensure("sub/package", dir=1) - tmpdir.mkdir(".git") - p.join("setup.py").write( - "from setuptools import setup;" 'setup(use_scm_version={"root": ".."})' - ) - - with pytest.raises(LookupError): - get_version(root=p.strpath, fallback_root=p.strpath) - - p.ensure("pip-egg-info/random.egg-info/PKG-INFO").write("Version: 1.0") - assert get_version(root=p.strpath, fallback_root=p.strpath) == "1.0" + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "0.1.dev0+d20090213" @pytest.mark.issue(164) -def test_pip_download(tmpdir, monkeypatch): - monkeypatch.chdir(tmpdir) +def test_pip_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) subprocess.check_call([sys.executable, "-m", "pip", "download", "lz4==0.9.0"]) -def test_use_scm_version_callable(tmpdir, monkeypatch): +def test_use_scm_version_callable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """use of callable as use_scm_version argument""" monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") - p = tmpdir.ensure("sub/package", dir=1) - p.join("setup.py").write( + p = tmp_path / "sub" / "package" + p.mkdir(parents=True) + p.joinpath("setup.py").write_text( """from setuptools import setup def vcfg(): from setuptools_scm.version import guess_next_dev_version @@ -74,17 +78,49 @@ def vcfg(): setup(use_scm_version=vcfg) """ ) - p.join("PKG-INFO").write("Version: 1.0") + p.joinpath("PKG-INFO").write_text("Version: 1.0") - res = do((sys.executable, "setup.py", "--version"), p) - assert res == "1.0" + res = run([sys.executable, "setup.py", "--version"], p) + assert res.stdout == "1.0" @pytest.mark.skipif(sys.platform != "win32", reason="this bug is only valid on windows") -def test_case_mismatch_on_windows_git(tmpdir): +def test_case_mismatch_on_windows_git(tmp_path: Path) -> None: """Case insensitive path checks on Windows""" - p = tmpdir.ensure("CapitalizedDir", dir=1) - - do("git init", p) - res = parse(str(p).lower()) + camel_case_path = tmp_path / "CapitalizedDir" + camel_case_path.mkdir() + run("git init", camel_case_path) + res = parse(str(camel_case_path).lower(), Configuration()) assert res is not None + + +def test_entrypoints_load() -> None: + d = distribution("setuptools-scm") + + eps = d.entry_points + failed: list[tuple[EntryPoint, Exception]] = [] + for ep in eps: + try: + ep.load() + except Exception as e: + failed.append((ep, e)) + if failed: + pytest.fail(pprint.pformat(failed)) + + +def test_write_to_absolute_path_passes_when_subdir_of_root(tmp_path: Path) -> None: + c = Configuration(root=tmp_path, write_to=tmp_path / "VERSION.py") + v = meta("1.0", config=c) + from setuptools_scm._get_version_impl import write_version_files + + with pytest.warns(DeprecationWarning, match=".*write_to=.* is a absolute.*"): + write_version_files(c, "1.0", v) + write_version_files(replace(c, write_to="VERSION.py"), "1.0", v) + subdir = tmp_path / "subdir" + subdir.mkdir() + with pytest.raises( + # todo: python version specific error list + ValueError, + match=".*VERSION.py' .* .*subdir.*", + ): + write_version_files(replace(c, root=subdir), "1.0", v) diff --git a/testing/test_setuptools_support.py b/testing/test_setuptools_support.py deleted file mode 100644 index ce3444e..0000000 --- a/testing/test_setuptools_support.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -integration tests that check setuptools version support -""" -import sys -import os -import subprocess -import pytest - -pytestmark = [ - pytest.mark.skipif( - "sys.version_info >= (3,6,0)", - reason="integration with old versions no longer needed on py3.6+", - ), - pytest.mark.xfail( - sys.platform == "win32", reason="path behaves unexpected on windows ci" - ), -] - - -@pytest.fixture(scope="session") -def get_setuptools_packagedir(request): - targets = request.config.cache.makedir("setuptools_installs") - - def makeinstall(version): - target = targets.ensure(version, dir=1) - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "--no-binary", - "setuptools", - "setuptools==" + version, - "-t", - str(target), - ] - ) - return target - - return makeinstall - - -SCRIPT = """ -from __future__ import print_function -import sys -import setuptools -print(setuptools.__version__, 'expected', sys.argv[1]) -import setuptools_scm.version -from setuptools_scm.__main__ import main -main() -""" - - -def check(packagedir, expected_version, **env): - - old_pythonpath = os.environ.get("PYTHONPATH") - if old_pythonpath: - pythonpath = "{}:{}".format(old_pythonpath, packagedir) - else: - pythonpath = str(packagedir) - subprocess.check_call( - [sys.executable, "-c", SCRIPT, expected_version], - env=dict(os.environ, PYTHONPATH=pythonpath, **env), - ) - - -def test_old_setuptools_fails(get_setuptools_packagedir): - packagedir = get_setuptools_packagedir("0.9.8") - with pytest.raises(subprocess.CalledProcessError): - check(packagedir, "0.9.8") - - -def test_old_setuptools_allows_with_warnings(get_setuptools_packagedir): - - packagedir = get_setuptools_packagedir("0.9.8") - # filter using warning since in the early python startup - check(packagedir, "0.9.8", PYTHONWARNINGS="once::Warning") - - -def test_distlib_setuptools_works(get_setuptools_packagedir): - packagedir = get_setuptools_packagedir("12.0.1") - check(packagedir, "12.0.1") diff --git a/testing/test_version.py b/testing/test_version.py index 0b487b3..ea4c7d9 100644 --- a/testing/test_version.py +++ b/testing/test_version.py @@ -1,18 +1,31 @@ +from __future__ import annotations + +from dataclasses import replace +from datetime import date +from datetime import timedelta +from typing import Any + import pytest -from setuptools_scm.config import Configuration -from setuptools_scm.version import ( - meta, - simplified_semver_version, - release_branch_semver_version, - tags_to_versions, -) + +from setuptools_scm import Configuration +from setuptools_scm import NonNormalizedVersion +from setuptools_scm.version import calver_by_date +from setuptools_scm.version import format_version +from setuptools_scm.version import guess_next_date_ver +from setuptools_scm.version import guess_next_version +from setuptools_scm.version import meta +from setuptools_scm.version import no_guess_dev_version +from setuptools_scm.version import release_branch_semver_version +from setuptools_scm.version import ScmVersion +from setuptools_scm.version import simplified_semver_version c = Configuration() +c_non_normalize = Configuration(version_cls=NonNormalizedVersion) @pytest.mark.parametrize( - "version, expected_next", + ("version", "expected_next"), [ pytest.param(meta("1.0.0", config=c), "1.0.0", id="exact"), pytest.param(meta("1.0", config=c), "1.0.0", id="short_tag"), @@ -43,13 +56,21 @@ c = Configuration() ), ], ) -def test_next_semver(version, expected_next): +def test_next_semver(version: ScmVersion, expected_next: str) -> None: computed = simplified_semver_version(version) assert computed == expected_next +def test_next_semver_bad_tag() -> None: + version = meta("1.0.0-foo", preformatted=True, config=c) + with pytest.raises( + ValueError, match=r"1\.0\.0-foo.* can't be parsed as numeric version" + ): + simplified_semver_version(version) + + @pytest.mark.parametrize( - "version, expected_next", + ("version", "expected_next"), [ pytest.param(meta("1.0.0", config=c), "1.0.0", id="exact"), pytest.param( @@ -67,6 +88,11 @@ def test_next_semver(version, expected_next): "1.0.1.dev2", id="release_branch_legacy_version", ), + pytest.param( + meta("1.0.0", distance=2, branch="v1.0.x", config=c), + "1.0.1.dev2", + id="release_branch_with_v_prefix", + ), pytest.param( meta("1.0.0", distance=2, branch="release-1.0", config=c), "1.0.1.dev2", @@ -79,33 +105,308 @@ def test_next_semver(version, expected_next): ), ], ) -def test_next_release_branch_semver(version, expected_next): +def test_next_release_branch_semver(version: ScmVersion, expected_next: str) -> None: computed = release_branch_semver_version(version) assert computed == expected_next +def m(tag: str, **kw: Any) -> ScmVersion: + return meta(tag, **kw, config=c) + + +@pytest.mark.parametrize( + ("version", "expected_next"), + [ + pytest.param( + m("1.0.0", distance=2), + "1.0.0.post1.dev2", + id="dev_distance", + ), + pytest.param( + m("1.0.dev0", distance=2), "1.0.dev2", id="dev_distance_after_dev_tag" + ), + pytest.param( + m("1.0", distance=2), + "1.0.post1.dev2", + id="dev_distance_short_tag", + ), + pytest.param( + m("1.0.0"), + "1.0.0", + id="no_dev_distance", + ), + ], +) +def test_no_guess_version(version: ScmVersion, expected_next: str) -> None: + computed = no_guess_dev_version(version) + assert computed == expected_next + + @pytest.mark.parametrize( - "tag, expected", + ("version", "match"), [ - pytest.param("v1.0.0", "1.0.0"), - pytest.param("v1.0.0-rc.1", "1.0.0rc1"), - pytest.param("v1.0.0-rc.1+-25259o4382757gjurh54", "1.0.0rc1"), + ("1.0.dev1", "choosing custom numbers for the `.devX` distance"), + ("1.0.post1", "already is a post release"), ], ) -def test_tag_regex1(tag, expected): - config = Configuration() +def test_no_guess_version_bad(version: str, match: str) -> None: + with pytest.raises(ValueError, match=match): + no_guess_dev_version(m(version, distance=1)) + + +def test_bump_dev_version_zero() -> None: + assert guess_next_version(m("1.0.dev0")) == "1.0" + + +def test_bump_dev_version_nonzero_raises() -> None: + match = ( + "choosing custom numbers for the `.devX` distance " + "is not supported.\n " + "The 1.0.dev1 can't be bumped\n" + "Please drop the tag or create a new supported one ending in .dev0" + ) + + with pytest.raises(ValueError, match=match): + guess_next_version(m("1.0.dev1")) + + +@pytest.mark.parametrize( + ("tag", "expected"), + [ + ("v1.0.0", "1.0.0"), + ("v1.0.0-rc.1", "1.0.0rc1"), + ("v1.0.0-rc.1+-25259o4382757gjurh54", "1.0.0rc1"), + ], +) +def test_tag_regex1(tag: str, expected: str) -> None: if "+" in tag: # pytest bug wrt cardinality with pytest.warns(UserWarning): - result = meta(tag, config=config) + result = meta(tag, config=c) else: - result = meta(tag, config=config) - + result = meta(tag, config=c) + assert not isinstance(result.tag, str) assert result.tag.public == expected -@pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/286") -def test_tags_to_versions(): - config = Configuration() - versions = tags_to_versions(["1.0", "2.0", "3.0"], config=config) - assert isinstance(versions, list) # enable subscription +@pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/471") +def test_version_bump_bad() -> None: + class YikesVersion: + val: str + + def __init__(self, val: str) -> None: + self.val = val + + def __str__(self) -> str: + return self.val + + config = Configuration(version_cls=YikesVersion) # type: ignore[arg-type] + with pytest.raises( + ValueError, + match=".*does not end with a number to bump, " + "please correct or use a custom version scheme", + ): + guess_next_version(tag_version=meta("2.0.0-alpha.5-PMC", config=config)) + + +def test_format_version_schemes() -> None: + version = meta( + "1.0", + config=replace( + c, + local_scheme="no-local-version", + version_scheme=[ # type: ignore[arg-type] + lambda v: None, + "guess-next-dev", + ], + ), + ) + assert format_version(version) == "1.0" + + +def test_custom_version_schemes() -> None: + version = meta( + "1.0", + config=replace( + c, + local_scheme="no-local-version", + version_scheme="setuptools_scm.version:no_guess_dev_version", + ), + ) + custom_computed = format_version(version) + assert custom_computed == no_guess_dev_version(version) + + +def date_offset(base_date: date | None = None, days_offset: int = 0) -> date: + if base_date is None: + from setuptools_scm.version import _source_epoch_or_utc_now + + base_date = _source_epoch_or_utc_now().date() + return base_date - timedelta(days=days_offset) + + +def date_to_str( + base_date: date | None = None, + days_offset: int = 0, + fmt: str = "%y.%m.%d", +) -> str: + return format(date_offset(base_date, days_offset), fmt) + + +@pytest.mark.parametrize( + ("version", "expected_next"), + [ + pytest.param( + meta(date_to_str(days_offset=3), config=c_non_normalize), + date_to_str(days_offset=3), + id="exact", + ), + pytest.param( + meta(date_to_str() + ".1", config=c_non_normalize), + date_to_str() + ".1", + id="exact patch", + ), + pytest.param( + meta("20.01.02", config=c), + "20.1.2", + id="leading 0s", + ), + pytest.param( + meta(date_to_str(days_offset=3), config=c_non_normalize, dirty=True), + date_to_str() + ".0.dev0", + id="dirty other day", + ), + pytest.param( + meta(date_to_str(), config=c_non_normalize, distance=2, branch="default"), + date_to_str() + ".1.dev2", + id="normal branch", + ), + pytest.param( + meta(date_to_str(fmt="%Y.%m.%d"), config=c_non_normalize), + date_to_str(fmt="%Y.%m.%d"), + id="4 digits year", + ), + pytest.param( + meta( + date_to_str(), + config=c_non_normalize, + distance=2, + branch="release-2021.05.06", + ), + "2021.05.06", + id="release branch", + ), + pytest.param( + meta( + date_to_str() + ".2", + config=c_non_normalize, + distance=2, + branch="release-21.5.1", + ), + "21.5.1", + id="release branch short", + ), + pytest.param( + meta( + date_to_str(days_offset=3) + ".2", + config=c_non_normalize, + node_date=date_offset(days_offset=2), + ), + date_to_str(days_offset=3) + ".2", + id="node date clean", + ), + pytest.param( + meta( + date_to_str(days_offset=2) + ".2", + config=c_non_normalize, + distance=2, + node_date=date_offset(days_offset=2), + ), + date_to_str(days_offset=2) + ".3.dev2", + id="node date distance", + ), + pytest.param( + meta( + "1.2.0", + config=c_non_normalize, + distance=2, + node_date=date_offset(days_offset=2), + ), + date_to_str(days_offset=2) + ".0.dev2", + marks=pytest.mark.filterwarnings( + "ignore:.*not correspond to a valid versioning date.*:UserWarning" + ), + id="using on old version tag", + ), + ], +) +def test_calver_by_date(version: ScmVersion, expected_next: str) -> None: + computed = calver_by_date(version) + assert computed == expected_next + + +@pytest.mark.parametrize( + ("version", "expected_next"), + [ + pytest.param(meta("1.0.0", config=c), "1.0.0", id="SemVer exact stays"), + pytest.param( + meta("1.0.0", config=c_non_normalize, dirty=True), + "09.02.13.1.dev0", + id="SemVer dirty is replaced by date", + marks=pytest.mark.filterwarnings("ignore:.*legacy version.*:UserWarning"), + ), + ], +) +def test_calver_by_date_semver(version: ScmVersion, expected_next: str) -> None: + computed = calver_by_date(version) + assert computed == expected_next + + +def test_calver_by_date_future_warning() -> None: + with pytest.warns(UserWarning, match="your previous tag*"): + calver_by_date( + meta(date_to_str(days_offset=-2), config=c_non_normalize, distance=2) + ) + + +@pytest.mark.parametrize( + ("tag", "node_date", "expected"), + [ + pytest.param("20.03.03", date(2020, 3, 4), "20.03.04.0", id="next day"), + pytest.param("20.03.03", date(2020, 3, 3), "20.03.03.1", id="same day"), + pytest.param( + "20.03.03.2", date(2020, 3, 3), "20.03.03.3", id="same day with patch" + ), + pytest.param( + "v20.03.03", date(2020, 3, 4), "v20.03.04.0", id="next day with v prefix" + ), + ], +) +def test_calver_guess_next_data(tag: str, node_date: date, expected: str) -> None: + version = meta(tag, config=c_non_normalize, node_date=node_date) + next = guess_next_date_ver( + version, + node_date=node_date, + version_cls=c_non_normalize.version_cls, + ) + assert next == expected + + +def test_custom_version_cls() -> None: + """Test that we can pass our own version class instead of pkg_resources""" + + class MyVersion: + def __init__(self, tag_str: str) -> None: + self.tag = tag_str + + def __str__(self) -> str: + return "Custom %s" % self.tag + + def __repr__(self) -> str: + return "MyVersion" % self.tag + + config = Configuration(version_cls=MyVersion) # type: ignore[arg-type] + scm_version = meta("1.0.0-foo", config=config) + + assert isinstance(scm_version.tag, MyVersion) + assert str(scm_version.tag) == "Custom 1.0.0-foo" diff --git a/testing/wd_wrapper.py b/testing/wd_wrapper.py new file mode 100644 index 0000000..dd504ee --- /dev/null +++ b/testing/wd_wrapper.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import itertools +from pathlib import Path +from typing import Any + + +class WorkDir: + """a simple model for a""" + + commit_command: str + signed_commit_command: str + add_command: str + + def __repr__(self) -> str: + return f"" + + def __init__(self, cwd: Path) -> None: + self.cwd = cwd + self.__counter = itertools.count() + + def __call__(self, cmd: list[str] | str, **kw: object) -> str: + if kw: + assert isinstance(cmd, str), "formatting the command requires text input" + cmd = cmd.format(**kw) + from setuptools_scm._run_cmd import run + + return run(cmd, cwd=self.cwd).stdout + + def write(self, name: str, content: str | bytes) -> Path: + path = self.cwd / name + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content) + return path + + def _reason(self, given_reason: str | None) -> str: + if given_reason is None: + return f"number-{next(self.__counter)}" + else: + return given_reason + + def add_and_commit( + self, reason: str | None = None, signed: bool = False, **kwargs: object + ) -> None: + self(self.add_command) + self.commit(reason=reason, signed=signed, **kwargs) + + def commit(self, reason: str | None = None, signed: bool = False) -> None: + reason = self._reason(reason) + self( + self.commit_command if not signed else self.signed_commit_command, + reason=reason, + ) + + def commit_testfile(self, reason: str | None = None, signed: bool = False) -> None: + reason = self._reason(reason) + self.write("test.txt", f"test {reason}") + self(self.add_command) + self.commit(reason=reason, signed=signed) + + def get_version(self, **kw: Any) -> str: + __tracebackhide__ = True + from setuptools_scm import get_version + + version = get_version(root=self.cwd, fallback_root=self.cwd, **kw) + print(self.cwd.name, version, sep=": ") + return version diff --git a/tox.ini b/tox.ini index 447c67e..e2da4b9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,83 +1,46 @@ [tox] -envlist=py{27,34,35,36,37,38}-test,flake8,check_readme,py{27,37}-selfcheck - -[pytest] -testpaths=testing -filterwarnings=error -markers= - issue(id): reference to github issue +envlist=py{38,39,310,311},check_readme,check-dist +requires= tox>4 [flake8] max-complexity = 10 max-line-length = 88 ignore=E203,W503 -exclude= - .git, - .tox, - .env, - .venv, - .pytest_cache, - __pycache__, - ./setuptools_scm/win_py31_compat.py [testenv] usedevelop=True -skip_install= - selfcheck: True - test: False deps= pytest - setuptools >= 42 + setuptools >= 45 + rich + build commands= - test: pytest [] - selfcheck: python setup.py --version -extras = - toml + pytest {posargs} + -[testenv:flake8] -skip_install=True -deps= - flake8 - mccabe -commands = - flake8 src/setuptools_scm/ testing/ setup.py --exclude=setuptools_scm/win_py31_compat.py [testenv:check_readme] skip_install=True -setenv = SETUPTOOLS_SCM_PRETEND_VERSION=2.0 deps= - readme check-manifest + docutils + pygments + typing_extensions + hatchling + rich commands= - python setup.py check -r - rst2html.py README.rst {envlogdir}/README.html --strict [] - check-manifest + check-manifest --no-build-isolation -[testenv:upload] +[testenv:check_dist] +skip_install = true deps= - wheel + build twine commands= - python setup.py clean --all rotate -k - -m .whl,.tar.gz,.zip - python setup.py -q egg_info - python setup.py -q sdist --formats zip bdist_wheel register - + python -m build + twine check dist/* -[testenv:dist] -deps= wheel -whitelist_externals = rm -commands= - python setup.py -q clean --all - python setup.py -q rotate -k 0 -m .egg,.zip,.whl,.tar.gz - python setup.py -q egg_info - python setup.py -q sdist --formats zip,bztar bdist_wheel upload -[testenv:devpi] -deps= - devpi-client -commands = - python setup.py -q egg_info - devpi upload --from-dir dist #XXX: envs for hg versions