diff --git a/.flake8 b/.flake8 deleted file mode 100644 index eceea15..0000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -# Ignore "and" at start of line. -ignore = W503 -max-line-length = 120 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..2fe3b5e --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,31 @@ +name: Check + +on: + - push + - pull_request + +jobs: + build: + name: ${{ matrix.tox-environment }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + tox-environment: + - lint + + env: + TOXENV: ${{ matrix.tox-environment }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v3 + + - name: Install dependencies + run: python -m pip install tox + + - name: Run + run: tox diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..50eee10 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,47 @@ +name: Test + +on: + - push + - pull_request + +jobs: + build: + name: Python ${{ matrix.python-version }} / ${{ matrix.django-family }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + # Django 3.2: Python 3.8, 3.9, 3.10 + - python-version: "3.8" + django-family: 32 + - python-version: "3.9" + django-family: 32 + - python-version: "3.10" + django-family: 32 + + # Django 4.1: Python 3.9, 3.10, 3.11 + - python-version: "3.9" + django-family: 41 + - python-version: "3.10" + django-family: 41 + - python-version: "3.11" + django-family: 41 + + env: + TOXENV: django${{ matrix.django-family }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: python -m pip install tox + + - name: Run tests + run: tox diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 735e554..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -sudo: false -language: python - -script: - - tox - -install: - - pip install tox - -matrix: - include: - - python: "3.4" - env: TOXENV=py34-django111 - - python: "3.5" - env: TOXENV=py35-django22 - - python: "3.6" - env: TOXENV=py36-django111 - env: TOXENV=py36-django22 - - # Pypy - - python: "pypy3" - env: TOXENV=pypy3-django22 - - # Linting - - python: "3.6" - env: TOXENV=lint - -notifications: - email: false - irc: "irc.freenode.org#xelnext" diff --git a/ChangeLog b/ChangeLog index 15f81be..2bf2e2c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,10 +1,103 @@ ChangeLog ========= -2.7.1 (unreleased) +2.10.1 (unreleased) +------------------- + +*Minor:* + + * `112 `_: + Functions returning a new ``Version`` instance reuse the current class, + helping with subclassing. + +*Bugfix:* + + * `141 `_: + Ensure we return a new instance for ``Version.truncate("build")``. + + +2.10.0 (2022-05-26) +------------------- + +*New:* + + * `132 `_: + Ensure sorting a collection of versions is always stable, even with + build metadata. + + +2.9.0 (2022-02-06) +------------------ + +*New:* + + * Add support for Django 3.1, 3.2, 4.0 + * Add support for Python 3.7 / 3.8 / 3.9 / 3.10 + + +2.8.5 (2020-04-29) +------------------ + +*Bugfix:* + + * `98 `_: + Properly handle wildcards in ``SimpleSpec`` (e.g. ``==1.2.*``). + + +2.8.4 (2019-12-21) +------------------ + +*Bugfix:* + + * `#89 `_: + Properly coerce versions with leading zeroes in components (e.g. + ``1.01.007``) + + +2.8.3 (2019-11-21) +------------------ + +*New:* + - Add `Clause.prettyprint()` for debugging + +*Bugfix:* + + * `#86 `_: + Fix handling of prerelease ranges within `NpmSpec` + + +2.8.2 (2019-09-06) +------------------ + +*Bugfix:* + + * `#82 `_: + Restore computation of ``Spec.specs`` for single-term expressions + (``>=0.1.2``) + + +2.8.1 (2019-08-29) ------------------ -- Nothing changed yet. +*Bugfix:* + + * Restored attribute ``Spec.specs``, removed by mistake during the refactor. + + +2.8.0 (2019-08-29) +------------------ + +*New:* + + * Restore support for Python 2. + + +2.7.1 (2019-08-28) +------------------ + +*Bugfix:* + + * Fix parsing of npm-based caret expressions. 2.7.0 (2019-08-28) @@ -26,7 +119,7 @@ Backwards compatibility has been kept, but users should adjust their code for th * Allow creation of a ``Version`` directly from parsed components, as keyword arguments (``Version(major=1, minor=2, patch=3)``) * Add ``Version.truncate()`` to build a truncated copy of a ``Version`` - * Add ``NpmSpec(...)``, following strict NPM matching rules (https://docs.npmjs.com/misc/semver) + * Add ``NpmSpec(...)``, following strict NPM matching rules (https://github.com/npm/node-semver#ranges) * Add ``Spec.parse('xxx', syntax='')`` for simpler multi-syntax support * Add ``Version().precedence_key``, for use in ``sort(versions, key=lambda v: v.precedence_key)`` calls. The contents of this attribute is an implementation detail. diff --git a/Makefile b/Makefile index c94363c..c617168 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ clean: update: pip install --upgrade pip setuptools - pip install --upgrade -r requirements_dev.txt + pip install --upgrade -e .[dev,doc] pip freeze @@ -47,7 +47,7 @@ testall: # DOC: Run tests for the currently installed version test: - python -Wdefault setup.py test + python -Wdefault -m nose2 # DOC: Perform code quality tasks lint: check-manifest flake8 @@ -59,8 +59,8 @@ check-manifest: # Note: we run the linter in two runs, because our __init__.py files has specific warnings we want to exclude # DOC: Verify code quality flake8: - $(FLAKE8) --config .flake8 --exclude $(PACKAGE)/__init__.py $(PACKAGE) $(TESTS_DIR) setup.py - $(FLAKE8) --config .flake8 --ignore F401 $(PACKAGE)/__init__.py + $(FLAKE8) --exclude $(PACKAGE)/__init__.py $(PACKAGE) $(TESTS_DIR) setup.py + $(FLAKE8) --ignore F401 $(PACKAGE)/__init__.py # DOC: Run tests with coverage collection coverage: diff --git a/README.rst b/README.rst index ef824ea..c50163a 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,11 @@ -python-semanticversion -====================== +Introduction +============ This small python library provides a few tools to handle `SemVer`_ in Python. It follows strictly the 2.0.0 version of the SemVer scheme. -.. image:: https://secure.travis-ci.org/rbarrois/python-semanticversion.png?branch=master - :target: http://travis-ci.org/rbarrois/python-semanticversion/ +.. image:: https://github.com/rbarrois/python-semanticversion/actions/workflows/test.yml/badge.svg + :target: https://github.com/rbarrois/python-semanticversion/actions/workflows/test.yml .. image:: https://img.shields.io/pypi/v/semantic_version.svg :target: https://python-semanticversion.readthedocs.io/en/latest/changelog.html @@ -26,10 +26,10 @@ It follows strictly the 2.0.0 version of the SemVer scheme. Links ----- -- Package on `PyPI`_: http://pypi.python.org/pypi/semantic_version/ +- Package on `PyPI`_: https://pypi.org/project/semantic-version/ - Doc on `ReadTheDocs `_: https://python-semanticversion.readthedocs.io/ - Source on `GitHub `_: http://github.com/rbarrois/python-semanticversion/ -- Build on `Travis CI `_: http://travis-ci.org/rbarrois/python-semanticversion/ +- Build on Github Actions: https://github.com/rbarrois/python-semanticversion/actions - Semantic Version specification: `SemVer`_ @@ -40,7 +40,7 @@ Install the package from `PyPI`_, using pip: .. code-block:: sh - pip install semantic_version + pip install semantic-version Or from GitHub: @@ -57,20 +57,18 @@ Import it in your code: import semantic_version -.. currentmodule:: semantic_version - This module provides classes to handle semantic versions: -- :class:`Version` represents a version number (``0.1.1-alpha+build.2012-05-15``) -- :class:`BaseSpec`-derived classes represent requirement specifications (``>=0.1.1,<0.3.0``): +- ``Version`` represents a version number (``0.1.1-alpha+build.2012-05-15``) +- ``BaseSpec``-derived classes represent requirement specifications (``>=0.1.1,<0.3.0``): - - :class:`SimpleSpec` describes a natural description syntax - - :class:`NpmSpec` is used for NPM-style range descriptions. + - ``SimpleSpec`` describes a natural description syntax + - ``NpmSpec`` is used for NPM-style range descriptions. Versions -------- -Defining a :class:`Version` is quite simple: +Defining a ``Version`` is quite simple: .. code-block:: pycon @@ -90,7 +88,7 @@ Defining a :class:`Version` is quite simple: >>> list(v) [0, 1, 1, [], []] -If the provided version string is invalid, a :exc:`ValueError` will be raised: +If the provided version string is invalid, a ``ValueError`` will be raised: .. code-block:: pycon @@ -104,7 +102,39 @@ If the provided version string is invalid, a :exc:`ValueError` will be raised: ValueError: Invalid version string: '0.1' -Obviously, :class:`Versions ` can be compared: +One may also create a ``Version`` with named components: + +.. code-block:: pycon + + >>> semantic_version.Version(major=0, minor=1, patch=2) + Version('0.1.2') + +In that case, ``major``, ``minor`` and ``patch`` are mandatory, and must be integers. +``prerelease`` and ``build``, if provided, must be tuples of strings: + +.. code-block:: pycon + + >>> semantic_version.Version(major=0, minor=1, patch=2, prerelease=('alpha', '2')) + Version('0.1.2-alpha.2') + + +Some user-supplied input might not match the semantic version scheme. +For such cases, the ``Version.coerce`` method will try to convert any +version-like string into a valid semver version: + +.. code-block:: pycon + + >>> Version.coerce('0') + Version('0.0.0') + >>> Version.coerce('0.1.2.3.4') + Version('0.1.2+3.4') + >>> Version.coerce('0.1.2a3') + Version('0.1.2-a3') + +Working with versions +""""""""""""""""""""" + +Obviously, versions can be compared: .. code-block:: pycon @@ -133,71 +163,71 @@ You can also get a new version that represents a bump in one of the version leve >>> str(new_v) '1.1.2' -It is also possible to check whether a given string is a proper semantic version string: -.. code-block:: pycon - - >>> semantic_version.validate('0.1.3') - True - >>> semantic_version.validate('0a2') - False - - -Finally, one may create a :class:`Version` with named components instead: - -.. code-block:: pycon - - >>> semantic_version.Version(major=0, minor=1, patch=2) - Version('0.1.2') +Requirement specification +------------------------- -In that case, ``major``, ``minor`` and ``patch`` are mandatory, and must be integers. -``prerelease`` and ``patch``, if provided, must be tuples of strings: +python-semanticversion provides a couple of ways to describe a range of accepted +versions: -.. code-block:: pycon +- The ``SimpleSpec`` class provides a simple, easily understood scheme -- + somewhat inspired from PyPI range notations; +- The ``NpmSpec`` class supports the whole NPM range specification scheme: - >>> semantic_version.Version(major=0, minor=1, patch=2, prerelease=('alpha', '2')) - Version('0.1.2-alpha.2') + .. code-block:: pycon + >>> Version('0.1.2') in NpmSpec('0.1.0-alpha.2 .. 0.2.4') + True + >>> Version('0.1.2') in NpmSpec('>=0.1.1 <0.1.3 || 2.x') + True + >>> Version('2.3.4') in NpmSpec('>=0.1.1 <0.1.3 || 2.x') + True -Requirement specification -------------------------- - -The :class:`SimpleSpec` object describes a range of accepted versions: +The ``SimpleSpec`` scheme +""""""""""""""""""""""""" +Basic usage is simply a comparator and a base version: .. code-block:: pycon >>> s = SimpleSpec('>=0.1.1') # At least 0.1.1 >>> s.match(Version('0.1.1')) True - >>> s.match(Version('0.1.1-alpha1')) # pre-release satisfy version spec - True + >>> s.match(Version('0.1.1-alpha1')) # pre-release doesn't satisfy version spec + False >>> s.match(Version('0.1.0')) False +Combining specifications can be expressed as follows: + + .. code-block:: pycon + + >>> SimpleSpec('>=0.1.1,<0.3.0') + Simpler test syntax is also available using the ``in`` keyword: .. code-block:: pycon >>> s = SimpleSpec('==0.1.1') - >>> Version('0.1.1-alpha1') in s + >>> Version('0.1.1+git7ccc72') in s # build variants are equivalent to full versions True + >>> Version('0.1.1-alpha1') in s # pre-release variants don't match the full version. + False >>> Version('0.1.2') in s False -Combining specifications can be expressed as follows: - - .. code-block:: pycon +Refer to the full documentation at +https://python-semanticversion.readthedocs.io/en/latest/ for more details on the +``SimpleSpec`` scheme. - >>> SimpleSpec('>=0.1.1,<0.3.0') Using a specification """"""""""""""""""""" -The :func:`SimpleSpec.filter` method filters an iterable of :class:`Version`: +The ``SimpleSpec.filter`` method filters an iterable of ``Version``: .. code-block:: pycon @@ -220,82 +250,6 @@ It is also possible to select the 'best' version from such iterables: Version('0.3.0') -Coercing an arbitrary version string -"""""""""""""""""""""""""""""""""""" - -Some user-supplied input might not match the semantic version scheme. -For such cases, the :meth:`Version.coerce` method will try to convert any -version-like string into a valid semver version: - -.. code-block:: pycon - - >>> Version.coerce('0') - Version('0.0.0') - >>> Version.coerce('0.1.2.3.4') - Version('0.1.2+3.4') - >>> Version.coerce('0.1.2a3') - Version('0.1.2-a3') - - -Including pre-release identifiers in specifications -""""""""""""""""""""""""""""""""""""""""""""""""""" - -When testing a :class:`Version` against a :class:`SimpleSpec`, comparisons are -adjusted for common user expectations; thus, a pre-release version (``1.0.0-alpha``) -will not satisfy the ``==1.0.0`` :class:`SimpleSpec`. - -Pre-release identifiers will only be compared if included in the :class:`BaseSpec` -definition or (for the empty pre-release number) if a single dash is appended -(``1.0.0-``): - - -.. code-block:: pycon - - >>> Version('0.1.0-alpha') in SimpleSpec('<0.1.0') # No pre-release identifier - False - >>> Version('0.1.0-alpha') in SimpleSpec('<0.1.0-') # Include pre-release in checks - True - - -Including build metadata in specifications -"""""""""""""""""""""""""""""""""""""""""" - -Build metadata has no ordering; thus, the only meaningful comparison including -build metadata is equality. - - -.. code-block:: pycon - - >>> Version('1.0.0+build2') in SimpleSpec('<=1.0.0') # Build metadata ignored - True - >>> Version('1.0.0+build1') in SimpleSpec('==1.0.0+build2') # Include build in checks - False - - -NPM-based ranges ----------------- - -The :class:`NpmSpec` class handles NPM-style ranges: - -.. code-block:: pycon - - >>> Version('1.2.3') in NpmSpec('1.2.2 - 1.4') - True - >>> Version('1.2.3') in NpmSpec('<1.x || >=1.2.3') - True - -Refer to https://docs.npmjs.com/misc/semver.html for a detailed description of NPM -range syntax. - - -Using with Django -================= - -The :mod:`semantic_version.django_fields` module provides django fields to -store :class:`Version` or :class:`BaseSpec` objects. - -More documentation is available in the :doc:`django` section. - Contributing ============ @@ -312,35 +266,14 @@ When submitting patches or pull requests, you should respect the following rules - Coding conventions are based on :pep:`8` - The whole test suite must pass after adding the changes - The test coverage for a new feature must be 100% -- New features and methods should be documented in the :doc:`reference` section - and included in the :doc:`changelog` -- Include your name in the :ref:`contributors` section +- New features and methods should be documented in the ``reference`` section + and included in the ``changelog`` +- Include your name in the ``contributors`` section .. note:: All files should contain the following header:: # -*- encoding: utf-8 -*- # Copyright (c) The python-semanticversion project - -Contents -======== - -.. toctree:: - :maxdepth: 2 - - reference - django - changelog - credits - - .. _SemVer: http://semver.org/ .. _PyPI: http://pypi.python.org/ - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/django.rst b/docs/django.rst index 34a0fe3..befa50a 100644 --- a/docs/django.rst +++ b/docs/django.rst @@ -37,4 +37,4 @@ with their :attr:`~django.db.models.CharField.max_length` defaulting to 200. The syntax to use for the field; defaults to ``'simple'``. - .. versionaddedd:: 2.7 + .. versionadded:: 2.7 diff --git a/docs/guide.rst b/docs/guide.rst new file mode 100644 index 0000000..9a7c5cd --- /dev/null +++ b/docs/guide.rst @@ -0,0 +1,346 @@ +Guide +===== + +.. currentmodule:: semantic_version + +This module covers the 2.0.0 version of the SemVer scheme, with additional +extensions: + +- Coercing any version string into a SemVer version, through + :meth:`Version.coerce`; +- Comparing versions; +- Computing next versions; +- Modelling version range specifcations, and choosing the best match -- for both + its custom logic, and NPM semantics (custom range specification schemes can + be added). + + +Version basics +-------------- + +Building :class:`Version` instances +""""""""""""""""""""""""""""""""""" + +The core of the module is the :class:`Version` class; it is usually instantiated +from a version string: + +.. code-block:: pycon + + >>> import semantic_version as semver + >>> v = semver.Version("0.1.1") + +The version's components are available through its attributes: + +* :attr:`~Version.major`, :attr:`~Version.minor`, :attr:`~Version.patch` are + integers: + + .. code-block:: pycon + + >>> v.major + 0 + >>> v.minor + 1 + >>> v.patch + 1 + + +* The :attr:`~Version.prerelease` and :attr:`~Version.build` attributes are + iterables of text elements: + + .. code-block:: pycon + + >>> v2 = semver.Version("0.1.1-dev+23.git2") + >>> v2.prerelease + ["dev"] + >>> v2.build + ["23", "git2"] + + +One may also build a :class:`Version` from named components directly: + +.. code-block:: pycon + + >>> semantic_version.Version(major=0, minor=1, patch=2) + Version('0.1.2') + + +In that case, ``major``, ``minor`` and ``patch`` are mandatory, and must be integers. +``prerelease`` and ``build``, if provided, must be tuples of strings: + +.. code-block:: pycon + + >>> semantic_version.Version(major=0, minor=1, patch=2, prerelease=('alpha', '2')) + Version('0.1.2-alpha.2') + + +If the provided version string is invalid, a :exc:`ValueError` will be raised: + +.. code-block:: pycon + + >>> semver.Version('0.1') + Traceback (most recent call last): + File "", line 1, in + File "/Users/rbarrois/dev/semantic_version/src/semantic_version/base.py", line 64, in __init__ + major, minor, patch, prerelease, build = self.parse(version_string, partial) + File "/Users/rbarrois/dev/semantic_version/src/semantic_version/base.py", line 86, in parse + raise ValueError('Invalid version string: %r' % version_string) + ValueError: Invalid version string: '0.1' + + +Working with non-SemVer version strings +""""""""""""""""""""""""""""""""""""""" + +Some user-supplied input might not match the semantic version scheme. +For such cases, the ``Version.coerce`` method will try to convert any +version-like string into a valid semver version: + +.. code-block:: pycon + + >>> semver.Version.coerce('0') + Version('0.0.0') + >>> semver.Version.coerce('0.1.2.3.4') + Version('0.1.2+3.4') + >>> semver.Version.coerce('0.1.2a3') + Version('0.1.2-a3') + + +Comparing versions +"""""""""""""""""" + +Versions can be compared, following the SemVer scheme: + +.. code-block:: pycon + + >>> semver.Version("0.1.0") < semver.Version("0.1.1") + True + >>> max( + ... semver.Version("0.1.0"), + ... semver.Version("0.2.2"), + ... semver.Version("0.1.1"), + ... semver.Version("0.2.2-rc1"), + ... ) + Version("0.2.2") + + +.. note:: + + As defined in SemVer, build metadata is ignored in comparisons, + but not in equalities: + + .. code-block:: pycon + + >>> semver.Version("0.1.2") <= semver.Version("0.1.2+git2") + True + >>> semver.Version("0.1.2") >= semver.Version("0.1.2+git2") + True + >>> semver.Version("0.1.2") == semver.Version("0.1.2+git2") + False + + +Iterating versions +"""""""""""""""""" + +One can get a new version that represents a bump in one of the version levels +through the :meth:`Version.next_major`, :meth:`Version.next_minor` or +:meth:`Version.next_patch` functions: + +.. code-block:: pycon + + >>> v = semver.Version('0.1.1+build') + >>> new_v = v.next_major() + >>> str(new_v) + '1.0.0' + >>> v = semver.Version('1.1.1+build') + >>> new_v = v.next_minor() + >>> str(new_v) + '1.2.0' + >>> v = semver.Version('1.1.1+build') + >>> new_v = v.next_patch() + >>> str(new_v) + '1.1.2' + +.. note:: + + * If the version includes :attr:`~Version.build` or + :attr:`~Version.prerelease` metadata, that value will be empty in the + next version; + * The next patch following a version with a pre-release identifier + is the same version with its prerelease and build identifiers removed: + ``Version("0.1.1-rc1").next_patch() == Version("0.1.1")`` + * Pre-release and build naming schemes are often custom and specific + to a project's internal design; thus, the library can't provide a + ``next_xxx`` method for those fields. + +One may also truncate versions through the :meth:`Version.truncate` method, +removing components beyond the selected level: + +.. code-block:: pycon + + >>> v = semver.Version("0.1.2-dev+git3") + >>> v.truncate("prerelease") + Version("0.1.2-dev") + >>> v.truncate("minor") + Version("0.1.0") + + +Range specifications +-------------------- + +Comparing version numbers isn't always enough; in many situations, one needs to +define a *range of acceptable versions*. + +That notion is not defined in SemVer; moreover, several systems exists, with +their own notations. + +The ``semantic_version`` package provides a couple of implementations for these +notions: + +- :class:`SimpleSpec` is a simple implementation, with reasonable expectations; +- :class:`NpmSpec` sticks to the NPM specification. + +Further schemes can be built in a similar manner, relying on the :class:`BaseSpec` +class for basics. + +Core API +"""""""" + +The core API is provided by the :class:`BaseSpec` class. + +.. note:: + + These examples use :class:`SimpleSpec` in order to be easily reproduced + by users, but only exhibit the standard parts of the interface. + +It is possible to check whether a given :class:`Version` matches a +:class:`BaseSpec` through :meth:`~BaseSpec.match`: + +.. code-block:: pycon + + >>> s = semver.SimpleSpec(">=0.1.1") + >>> s.match(Version("0.1.1")) + True + >>> s.match(Version("0.1.0")) + False + +This feature is also available through the ``in`` keyword: + +.. code-block:: pycon + + >>> s = semver.SimpleSpec(">=0.1.1") + >>> Version("0.1.1") in s + True + >>> Version("0.1.0") in s + False + +A specification can filter compatible values from an iterable of versions +with :meth:`~BaseSpec.filter`: + +.. code-block:: pycon + + >>> s = semver.SimpleSpec(">=0.2.1") + >>> versions = [ + ... Version("0.1.0"), + ... Version("0.2.0"), + ... Version("0.3.0"), + ... Version("0.4.0"), + ... ] + >>> list(s.filter(versions)) + [Version("0.3.0"), Version("0.4.0")] + +It can also select the "best" version from such an iterable through +:meth:`~BaseSpec.select`: + +.. code-block:: pycon + + >>> s = semver.SimpleSpec(">=0.2.1") + >>> versions = [ + ... Version("0.1.0"), + ... Version("0.2.0"), + ... Version("0.3.0"), + ... Version("0.4.0"), + ... ] + >>> s.select(versions) + Version("0.4.0") + + +The :class:`SimpleSpec` scheme +"""""""""""""""""""""""""""""" + +The :class:`SimpleSpec` provides a hopefully intuitive version range +specification scheme: + +- A specification expression is composed of comma-separated clauses; +- Each clause can be: + + - An equality match (``==`` or ``!=``); + - A comparison (``>``, ``>=``, ``<`` , ``<=``); + - A compatible release clause, PyPI style (``~=2.2`` for ``>=2.2.0,<3.0.0``); + - An NPM style clause: + + - ``~1.2.3`` for ``>=1.2.3,<1.3.0``; + - ``^1.3.4`` for ``>=1.3.4,<2.0.0``; + +- The range in each clause may include a wildcard: + + * ``==0.1.*`` maps to ``>=0.1.0,<0.2.0``; + * ``==1.*`` or ``==1.*.*`` map to ``>=1.0.0,<2.0.0`` + + +.. rubric:: Special matching rules + +When testing a :class:`Version` against a :class:`SimpleSpec`, comparisons are +adjusted for common user expectations; thus, a pre-release version (``1.0.0-alpha``) +will not satisfy the ``==1.0.0`` :class:`SimpleSpec`. + +Pre-release identifiers will only be compared if included in the :class:`BaseSpec` +definition or (for the empty pre-release number) if a single dash is appended +(``1.0.0-``): + + +.. code-block:: pycon + + >>> Version('0.1.0-alpha') in SimpleSpec('<0.1.0') # No pre-release identifier + False + >>> Version('0.1.0-alpha') in SimpleSpec('<0.1.0-') # Include pre-release in checks + True + + +Build metadata has no ordering; thus, the only meaningful comparison including +build metadata is equality: + + +.. code-block:: pycon + + >>> Version('1.0.0+build2') in SimpleSpec('<=1.0.0') # Build metadata ignored + True + >>> Version('1.0.0+build1') in SimpleSpec('==1.0.0+build2') # Include build in checks + False + +.. note:: + + The full documentation is provided in the reference section + for the :class:`SimpleSpec` class. + + +The :class:`NpmSpec` scheme +""""""""""""""""""""""""""" + +The :class:`NpmSpec` class implements the full NPM specification (from +https://github.com/npm/node-semver#ranges): + +.. code-block:: pycon + + >>> semver.Version("0.1.2") in semver.NpmSpec("0.1.0-alpha.2 .. 0.2.4") + True + >>> semver.Version('0.1.2') in semver.NpmSpec('>=0.1.1 <0.1.3 || 2.x') + True + >>> semver.Version('2.3.4') in semver.NpmSpec('>=0.1.1 <0.1.3 || 2.x') + True + +Using with Django +----------------- + +The :mod:`semantic_version.django_fields` module provides django fields to +store :class:`Version` or :class:`BaseSpec` objects. + +More documentation is available in the :doc:`django` section. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 120000 index 89a0106..0000000 --- a/docs/index.rst +++ /dev/null @@ -1 +0,0 @@ -../README.rst \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..135ee68 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,30 @@ +====================== +python-semanticversion +====================== + +.. include:: introduction.rst + + +Contents +======== + +.. toctree:: + :maxdepth: 2 + + introduction + guide + reference + django + changelog + credits + + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 120000 index 0000000..89a0106 --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1 @@ +../README.rst \ No newline at end of file diff --git a/docs/reference.rst b/docs/reference.rst index f187667..b2946d9 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -66,6 +66,7 @@ Representing a version (the Version class) ------------------------------------------ .. class:: Version(version_string[, partial=False]) + :noindex: Object representation of a `SemVer`_-compliant version. @@ -90,15 +91,6 @@ Representing a version (the Version class) .. rubric:: Attributes - .. attribute:: partial - - ``bool``, whether this is a 'partial' or a complete version number. - Partial version number may lack :attr:`minor` or :attr:`patch` version numbers. - - .. deprecated:: 2.7 - The ability to define a partial version will be removed in version 3.0. - Use :class:`SimpleSpec` instead: ``SimpleSpec('1.x.x')``. - .. attribute:: major ``int``, the major version number @@ -138,7 +130,25 @@ Representing a version (the Version class) The actual value of the attribute is considered an implementation detail; the only guarantee is that ordering versions by their precedence_key will comply with semver precedence rules. - Note that the :attr:`~Version.build` isn't included in the precedence_key computatin. + + .. warning:: + + .. versionchanged:: 2.10.0 + + The :attr:`~Version.build` is included in the precedence_key computation, but + only for ordering stability. + The only guarantee is that, for a given release of python-semanticversion, two versions' + :attr:`~Version.precedence_key` will always compare in the same direction if they include + build metadata; that ordering is an implementation detail and shouldn't be relied upon. + + .. attribute:: partial + + ``bool``, whether this is a 'partial' or a complete version number. + Partial version number may lack :attr:`minor` or :attr:`patch` version numbers. + + .. deprecated:: 2.7 + The ability to define a partial version will be removed in version 3.0. + Use :class:`SimpleSpec` instead: ``SimpleSpec('1.x.x')``. .. rubric:: Methods @@ -173,7 +183,7 @@ Representing a version (the Version class) >>> Version('1.1.0-alpha').next_minor() Version('1.1.0') - .. method:: next_patch(self): + .. method:: next_patch(self) Return the next patch version, i.e the smallest version strictly greater than the current one with empty :attr:`prerelease` and :attr:`build`. @@ -231,6 +241,7 @@ Representing a version (the Version class) - For non-:attr:`partial` versions, compare using the `SemVer`_ scheme - If any compared object is :attr:`partial`: + - Begin comparison using the `SemVer`_ scheme - If a component (:attr:`minor`, :attr:`patch`, :attr:`prerelease` or :attr:`build`) was absent from the :attr:`partial` :class:`Version` -- represented with :obj:`None` @@ -334,7 +345,7 @@ In order to solve this problem, each `SemVer`_-based package management platform python-semanticversion provides a couple of implementations of those range definition syntaxes: - ``'simple'`` (through :class:`SimpleSpec`): A python-semanticversion specific syntax, which supports simple / intuitive patterns, and some NPM-inspired extensions; -- ``'npm'`` (through :class:`NpmSpec`): The NPM syntax, based on https://docs.npmjs.com/misc/semver.html +- ``'npm'`` (through :class:`NpmSpec`): The NPM syntax, based on https://github.com/npm/node-semver#ranges - More might be added in the future. Each of those ``Spec`` classes provides a shared set of methods to work with versions: @@ -470,7 +481,16 @@ Each of those ``Spec`` classes provides a shared set of methods to work with ver * A clause of ``<=XXX`` will match versions that match ``0.1.2`` will match versions strictly above ``0.1.2``, including all prereleases of ``0.1.3``. * A clause of ``>0.1.2-rc.3`` will match versions strictly above ``0.1.2-rc.3``, including matching prereleases of ``0.1.2``: ``0.1.2-rc.10`` is included; - * A clause of ``<=XXX`` will match versions that match ``>XXX`` or ``==XXX`` + * A clause of ``>=XXX`` will match versions that match ``>XXX`` or ``==XXX`` + + .. rubric:: Wildcards + + * A clause of ``==0.1.*`` is equivalent to ``>=0.1.0,<0.2.0`` + * A clause of ``>=0.1.*`` is equivalent to ``>=0.1.0`` + * A clause of ``==1.*`` or ``==1.*.*`` is equivalent to ``>=1.0.0,<2.0.0`` + * A clause of ``>=1.*`` or ``>=1.*.*`` is equivalent to ``>=1.0.0`` + * A clause of ``==*`` maps to ``>=0.0.0`` + * A clause of ``>=*`` maps to ``>=0.0.0`` .. rubric:: Extensions @@ -507,7 +527,7 @@ Each of those ``Spec`` classes provides a shared set of methods to work with ver .. versionadded:: 2.7 - A NPM-compliant version matching engine, based on the https://docs.npmjs.com/misc/semver.html specification. + A NPM-compliant version matching engine, based on the https://github.com/npm/node-semver#ranges specification. .. code-block:: pycon @@ -545,7 +565,7 @@ Each of those ``Spec`` classes provides a shared set of methods to work with ver , )> - Its keeps a list of :class:`SpecItem` objects, based on the initial expression + It keeps a list of :class:`SpecItem` objects, based on the initial expression components. .. method:: __iter__(self) @@ -696,7 +716,13 @@ Each of those ``Spec`` classes provides a shared set of methods to work with ver >>> Version('1.0.1') in Spec('!=1.0.1') False - The kind of 'Almost equal to' specifications + .. data:: KIND_COMPATIBLE + + The kind of `compatible release clauses`_ + specifications:: + + >>> Version('1.1.2') in Spec('~=1.1.0') + True diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e18794f..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# No hard external requirements. diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 741f337..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Requirements for local development --e . --r requirements_test.txt - -Django>=1.11 - -coverage -wheel -tox - -Sphinx -sphinx_rtd_theme - -zest.releaser[recommended] diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index 35012df..0000000 --- a/requirements_test.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Common requirements for running tests -check_manifest -flake8 diff --git a/semantic_version/__init__.py b/semantic_version/__init__.py index 125435f..1528bda 100644 --- a/semantic_version/__init__.py +++ b/semantic_version/__init__.py @@ -7,4 +7,12 @@ __author__ = "Raphaël Barrois " -__version__ = '2.7.1.dev0' +try: + # Python 3.8+ + from importlib.metadata import version + + __version__ = version("semantic_version") +except ImportError: + import pkg_resources + + __version__ = pkg_resources.get_distribution("semantic_version").version diff --git a/semantic_version/base.py b/semantic_version/base.py index fb8205c..6be5624 100644 --- a/semantic_version/base.py +++ b/semantic_version/base.py @@ -14,7 +14,7 @@ def _has_leading_zero(value): and value != '0') -class MaxIdentifier: +class MaxIdentifier(object): __slots__ = [] def __repr__(self): @@ -25,7 +25,7 @@ def __eq__(self, other): @functools.total_ordering -class NumericIdentifier: +class NumericIdentifier(object): __slots__ = ['value'] def __init__(self, value): @@ -51,7 +51,7 @@ def __lt__(self, other): @functools.total_ordering -class AlphaIdentifier: +class AlphaIdentifier(object): __slots__ = ['value'] def __init__(self, value): @@ -76,7 +76,7 @@ def __lt__(self, other): return NotImplemented -class Version: +class Version(object): version_re = re.compile(r'^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-zA-Z.-]+))?(?:\+([0-9a-zA-Z.-]+))?$') partial_version_re = re.compile(r'^(\d+)(?:\.(\d+)(?:\.(\d+))?)?(?:-([0-9a-zA-Z.-]*))?(?:\+([0-9a-zA-Z.-]*))?$') @@ -84,7 +84,6 @@ class Version: def __init__( self, version_string=None, - *, major=None, minor=None, patch=None, @@ -119,6 +118,12 @@ def __init__( self.partial = partial + # Cached precedence keys + # _cmp_precedence_key is used for semver-precedence comparison + self._cmp_precedence_key = self._build_precedence_key(with_build=False) + # _sort_precedence_key is used for self.precedence_key, esp. for sorted(...) + self._sort_precedence_key = self._build_precedence_key(with_build=True) + @classmethod def _coerce(cls, value, allow_none=False): if value is None and allow_none: @@ -127,14 +132,14 @@ def _coerce(cls, value, allow_none=False): def next_major(self): if self.prerelease and self.minor == self.patch == 0: - return Version( + return self.__class__( major=self.major, minor=0, patch=0, partial=self.partial, ) else: - return Version( + return self.__class__( major=self.major + 1, minor=0, patch=0, @@ -143,14 +148,14 @@ def next_major(self): def next_minor(self): if self.prerelease and self.patch == 0: - return Version( + return self.__class__( major=self.major, minor=self.minor, patch=0, partial=self.partial, ) else: - return Version( + return self.__class__( major=self.major, minor=self.minor + 1, patch=0, @@ -159,14 +164,14 @@ def next_minor(self): def next_patch(self): if self.prerelease: - return Version( + return self.__class__( major=self.major, minor=self.minor, patch=self.patch, partial=self.partial, ) else: - return Version( + return self.__class__( major=self.major, minor=self.minor, patch=self.patch + 1, @@ -176,9 +181,16 @@ def next_patch(self): def truncate(self, level='patch'): """Return a new Version object, truncated up to the selected level.""" if level == 'build': - return self + return self.__class__( + major=self.major, + minor=self.minor, + patch=self.patch, + prerelease=self.prerelease, + build=self.build, + partial=self.partial, + ) elif level == 'prerelease': - return Version( + return self.__class__( major=self.major, minor=self.minor, patch=self.patch, @@ -186,21 +198,21 @@ def truncate(self, level='patch'): partial=self.partial, ) elif level == 'patch': - return Version( + return self.__class__( major=self.major, minor=self.minor, patch=self.patch, partial=self.partial, ) elif level == 'minor': - return Version( + return self.__class__( major=self.major, minor=self.minor, patch=None if self.partial else 0, partial=self.partial, ) elif level == 'major': - return Version( + return self.__class__( major=self.major, minor=None if self.partial else 0, patch=None if self.partial else 0, @@ -245,8 +257,16 @@ def coerce(cls, version_string, partial=False): while version.count('.') < 2: version += '.0' + # Strip leading zeros in components + # Version is of the form nn, nn.pp or nn.pp.qq + version = '.'.join( + # If the part was '0', we end up with an empty string. + part.lstrip('0') or '0' + for part in version.split('.') + ) + if match.end() == len(version_string): - return Version(version, partial=partial) + return cls(version, partial=partial) rest = version_string[match.end():] @@ -283,7 +303,8 @@ def coerce(cls, version_string, partial=False): @classmethod def parse(cls, version_string, partial=False, coerce=False): - """Parse a version string into a Version() object. + """Parse a version string into a tuple of components: + (major, minor, patch, prerelease, build). Args: version_string (str), the version string to parse @@ -400,11 +421,15 @@ def __hash__(self): # at least a field being `None`. return hash((self.major, self.minor, self.patch, self.prerelease, self.build)) - @property - def precedence_key(self): + def _build_precedence_key(self, with_build=False): + """Build a precedence key. + + The "build" component should only be used when sorting an iterable + of versions. + """ if self.prerelease: prerelease_key = tuple( - NumericIdentifier(part) if part.isdecimal() else AlphaIdentifier(part) + NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part) for part in self.prerelease ) else: @@ -412,13 +437,31 @@ def precedence_key(self): MaxIdentifier(), ) + if not with_build: + return ( + self.major, + self.minor, + self.patch, + prerelease_key, + ) + + build_key = tuple( + NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part) + for part in self.build or () + ) + return ( self.major, self.minor, self.patch, prerelease_key, + build_key, ) + @property + def precedence_key(self): + return self._sort_precedence_key + def __cmp__(self, other): if not isinstance(other, self.__class__): return NotImplemented @@ -450,25 +493,25 @@ def __ne__(self, other): def __lt__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self.precedence_key < other.precedence_key + return self._cmp_precedence_key < other._cmp_precedence_key def __le__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self.precedence_key <= other.precedence_key + return self._cmp_precedence_key <= other._cmp_precedence_key def __gt__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self.precedence_key > other.precedence_key + return self._cmp_precedence_key > other._cmp_precedence_key def __ge__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return self.precedence_key >= other.precedence_key + return self._cmp_precedence_key >= other._cmp_precedence_key -class SpecItem: +class SpecItem(object): """A requirement specification.""" KIND_ANY = '*' @@ -576,7 +619,7 @@ def validate(version_string): DEFAULT_SYNTAX = 'simple' -class BaseSpec: +class BaseSpec(object): """A specification of compatible versions. Usage: @@ -606,7 +649,7 @@ def register_syntax(cls, subclass): return subclass def __init__(self, expression): - super().__init__() + super(BaseSpec, self).__init__() self.expression = expression self.clause = self._parse_to_clause(expression) @@ -659,7 +702,7 @@ def __repr__(self): return '<%s: %r>' % (self.__class__.__name__, self.expression) -class Clause: +class Clause(object): __slots__ = [] def match(self, version): @@ -674,6 +717,19 @@ def __or__(self, other): def __eq__(self, other): raise NotImplementedError() + def prettyprint(self, indent='\t'): + """Pretty-print the clause. + """ + return '\n'.join(self._pretty()).replace('\t', indent) + + def _pretty(self): + """Actual pretty-printing logic. + + Yields: + A list of string. Indentation is performed with \t. + """ + yield repr(self) + def __ne__(self, other): return not self == other @@ -685,7 +741,7 @@ class AnyOf(Clause): __slots__ = ['clauses'] def __init__(self, *clauses): - super().__init__() + super(AnyOf, self).__init__() self.clauses = frozenset(clauses) def match(self, version): @@ -734,12 +790,21 @@ def __or__(self, other): def __repr__(self): return 'AnyOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + def _pretty(self): + yield 'AnyOF(' + for clause in self.clauses: + lines = list(clause._pretty()) + for line in lines[:-1]: + yield '\t' + line + yield '\t' + lines[-1] + ',' + yield ')' + class AllOf(Clause): __slots__ = ['clauses'] def __init__(self, *clauses): - super().__init__() + super(AllOf, self).__init__() self.clauses = frozenset(clauses) def match(self, version): @@ -790,6 +855,15 @@ def __or__(self, other): def __repr__(self): return 'AllOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + def _pretty(self): + yield 'AllOF(' + for clause in self.clauses: + lines = list(clause._pretty()) + for line in lines[:-1]: + yield '\t' + line + yield '\t' + lines[-1] + ',' + yield ')' + class Matcher(Clause): __slots__ = [] @@ -878,7 +952,7 @@ class Range(Matcher): __slots__ = ['operator', 'target', 'prerelease_policy', 'build_policy'] def __init__(self, operator, target, prerelease_policy=PRERELEASE_NATURAL, build_policy=BUILD_IMPLICIT): - super().__init__() + super(Range, self).__init__() if target.build and operator not in (self.OP_EQ, self.OP_NEQ): raise ValueError( "Invalid range %s%s: build numbers have no ordering." @@ -1085,7 +1159,7 @@ def parse_block(cls, expr): elif minor is None: return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_major()) elif patch is None: - return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_patch()) + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_minor()) elif build == '': return Range(Range.OP_EQ, target, build_policy=Range.BUILD_STRICT) else: @@ -1155,7 +1229,11 @@ def __init__(self, *expressions): stacklevel=2, ) expression = ','.join(expressions) - super().__init__(expression) + super(LegacySpec, self).__init__(expression) + + @property + def specs(self): + return list(self) def __iter__(self): warnings.warn( @@ -1163,7 +1241,11 @@ def __iter__(self): DeprecationWarning, stacklevel=2, ) - for clause in self.clause: + try: + clauses = list(self.clause) + except TypeError: # Not an iterable + clauses = [self.clause] + for clause in clauses: yield SpecItem.from_matcher(clause) @@ -1186,7 +1268,7 @@ class Parser: PART = r'[a-zA-Z0-9.-]*' NPM_SPEC_BLOCK = re.compile(r""" ^(?:v)? # Strip optional initial v - (?P<|<=|>=|>|=|^|~|) # Operator, can be empty + (?P<|<=|>=|>|=|\^|~|) # Operator, can be empty (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? (?:-(?P{part}))? # Optional re-release (?:\+(?P{part}))? # Optional build @@ -1235,7 +1317,7 @@ def parse(cls, expression): prerelease_policy=Range.PRERELEASE_ALWAYS, )) elif clause.operator in (Range.OP_LT, Range.OP_LTE): - prerelease_clauses.append(cls.range( + prerelease_clauses.append(Range( operator=Range.OP_GTE, target=Version( major=clause.target.major, @@ -1243,6 +1325,7 @@ def parse(cls, expression): patch=0, prerelease=(), ), + prerelease_policy=Range.PRERELEASE_ALWAYS, )) prerelease_clauses.append(clause) non_prerel_clauses.append(cls.range( @@ -1316,13 +1399,17 @@ def parse_simple(cls, simple): raise ValueError("Invalid NPM spec: %r" % simple) if prefix == cls.PREFIX_CARET: - if target.major: # ^1.2.4 => >=1.2.4 <2.0.0 - high = target.next_major() + if target.major: # ^1.2.4 => >=1.2.4 <2.0.0 ; ^1.x => >=1.0.0 <2.0.0 + high = target.truncate().next_major() elif target.minor: # ^0.1.2 => >=0.1.2 <0.2.0 - high = target.next_minor() + high = target.truncate().next_minor() + elif minor is None: # ^0.x => >=0.0.0 <1.0.0 + high = target.truncate().next_major() + elif patch is None: # ^0.2.x => >=0.2.0 <0.3.0 + high = target.truncate().next_minor() else: # ^0.0.1 => >=0.0.1 <0.0.2 - high = target.next_patch() - return [cls.range(Range.OP_GTE, target), cls.Range(Range.OP_LT, high)] + high = target.truncate().next_patch() + return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)] elif prefix == cls.PREFIX_TILDE: assert major is not None diff --git a/semantic_version/django_fields.py b/semantic_version/django_fields.py index db7e606..e5bd7eb 100644 --- a/semantic_version/django_fields.py +++ b/semantic_version/django_fields.py @@ -4,8 +4,14 @@ import warnings +import django from django.db import models -from django.utils.translation import ugettext_lazy as _ + +if django.VERSION >= (3, 0): + # See https://docs.djangoproject.com/en/dev/releases/3.0/#features-deprecated-in-3-0 + from django.utils.translation import gettext_lazy as _ +else: + from django.utils.translation import ugettext_lazy as _ from . import base @@ -14,9 +20,9 @@ class SemVerField(models.CharField): def __init__(self, *args, **kwargs): kwargs.setdefault('max_length', 200) - super().__init__(*args, **kwargs) + super(SemVerField, self).__init__(*args, **kwargs) - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection, *args): """Convert from the database format. This should be the inverse of self.get_prep_value() @@ -36,7 +42,7 @@ def value_to_string(self, obj): return str(value) def run_validators(self, value): - return super().run_validators(str(value)) + return super(SemVerField, self).run_validators(str(value)) class VersionField(SemVerField): @@ -54,11 +60,11 @@ def __init__(self, *args, **kwargs): stacklevel=2, ) self.coerce = kwargs.pop('coerce', False) - super().__init__(*args, **kwargs) + super(VersionField, self).__init__(*args, **kwargs) def deconstruct(self): """Handle django.db.migrations.""" - name, path, args, kwargs = super().deconstruct() + name, path, args, kwargs = super(VersionField, self).deconstruct() kwargs['partial'] = self.partial kwargs['coerce'] = self.coerce return name, path, args, kwargs @@ -83,11 +89,11 @@ class SpecField(SemVerField): def __init__(self, *args, **kwargs): self.syntax = kwargs.pop('syntax', base.DEFAULT_SYNTAX) - super().__init__(*args, **kwargs) + super(SpecField, self).__init__(*args, **kwargs) def deconstruct(self): """Handle django.db.migrations.""" - name, path, args, kwargs = super().deconstruct() + name, path, args, kwargs = super(SpecField, self).deconstruct() if self.syntax != base.DEFAULT_SYNTAX: kwargs['syntax'] = self.syntax return name, path, args, kwargs diff --git a/setup.cfg b/setup.cfg index e6ff787..46a2673 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,15 +1,72 @@ -[bdist_wheel] -universal = 1 - [metadata] +name = semantic_version +version = 2.10.1.dev0 +description = A library implementing the 'SemVer' scheme. +long_description = file: README.rst +# https://docutils.sourceforge.io/FAQ.html#what-s-the-official-mime-type-for-restructuredtext-data +long_description_content_type = text/x-rst +author = Raphaël Barrois +author_email = raphael.barrois+semver@polytechnique.org +url = https://github.com/rbarrois/python-semanticversion +keywords = semantic version, versioning, version +license = BSD license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Topic :: Software Development :: Libraries :: Python Modules + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Topic :: Software Development :: Libraries :: Python Modules + +[options] +zip_safe = false +packages = semantic_version +python_requires = >= 2.7 +install_requires = + +[options.extras_require] +dev = + Django>=1.11 + # Runners + nose2 + tox + # Quality + check_manifest + coverage + flake8 + # Packaging + wheel + zest.releaser[recommended] + readme_renderer<25.0; python_version == "3.4" + colorama<=0.4.1; python_version == "3.4" + +doc = + Sphinx + sphinx_rtd_theme + +[bdist_wheel] +universal = 1 [zest.releaser] ; semver-style versions version-levels = 3 -; Version flag location (we use __version__) -python-file-with-version = semantic_version/__init__.py - [distutils] index-servers = pypi + +[flake8] +# Ignore "and" at start of line. +ignore = W503 +max-line-length = 120 diff --git a/setup.py b/setup.py index 2c52217..02a989b 100755 --- a/setup.py +++ b/setup.py @@ -3,70 +3,6 @@ # Copyright (c) The python-semanticversion project -import codecs -import os -import re - from setuptools import setup -root_dir = os.path.abspath(os.path.dirname(__file__)) - - -def get_version(package_name): - version_re = re.compile(r"^__version__ = [\"']([\w_.-]+)[\"']$") - package_components = package_name.split('.') - init_path = os.path.join(root_dir, *(package_components + ['__init__.py'])) - with codecs.open(init_path, 'r', 'utf-8') as f: - for line in f: - match = version_re.match(line[:-1]) - if match: - return match.groups()[0] - return '0.1.0' - - -def clean_readme(fname): - """Cleanup README.rst for proper PyPI formatting.""" - with codecs.open(fname, 'r', 'utf-8') as f: - return ''.join( - re.sub(r':\w+:`([^`]+?)( <[^<>]+>)?`', r'``\1``', line) - for line in f - if not (line.startswith('.. currentmodule') or line.startswith('.. toctree')) - ) - - -PACKAGE = 'semantic_version' - - -setup( - name=PACKAGE, - version=get_version(PACKAGE), - author="Raphaël Barrois", - author_email="raphael.barrois+semver@polytechnique.org", - description="A library implementing the 'SemVer' scheme.", - long_description=clean_readme('README.rst'), - license='BSD', - keywords=['semantic version', 'versioning', 'version'], - url='https://github.com/rbarrois/python-semanticversion', - download_url='http://pypi.python.org/pypi/semantic_version/', - packages=['semantic_version'], - python_requires=">=3.4", - setup_requires=[ - 'setuptools>=0.8', - ], - zip_safe=False, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Topic :: Software Development :: Libraries :: Python Modules' - ], - test_suite='tests', -) +setup() diff --git a/tests/test_base.py b/tests/test_base.py index 5a6497b..4136045 100755 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -6,6 +6,7 @@ """Test the various functions from 'base'.""" import unittest +import sys from semantic_version import base @@ -13,6 +14,13 @@ class TopLevelTestCase(unittest.TestCase): """Test module-level functions.""" + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + versions = ( ('0.1.0', '0.1.1', -1), ('0.1.1', '0.1.1', 0), @@ -89,6 +97,13 @@ def test_validate_invalid(self): class VersionTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + versions = { '1.0.0-alpha': (1, 0, 0, ('alpha',), ()), '1.0.0-alpha.1': (1, 0, 0, ('alpha', '1'), ()), @@ -199,6 +214,7 @@ def test_hash(self): ])) ) + @unittest.skipIf(sys.version_info[0] <= 2, "Comparisons don't raise TypeError in Python 2") def test_invalid_comparisons(self): v = base.Version('0.1.0') with self.assertRaises(TypeError): @@ -213,6 +229,20 @@ def test_invalid_comparisons(self): self.assertTrue(v != '0.1.0') self.assertFalse(v == '0.1.0') + def test_stable_ordering(self): + a = [ + base.Version('0.1.0'), + base.Version('0.1.0+a'), + base.Version('0.1.0+a.1'), + base.Version('0.1.1-a1'), + ] + b = [a[1], a[3], a[0], a[2]] + + self.assertEqual( + sorted(a, key=lambda v: v.precedence_key), + sorted(b, key=lambda v: v.precedence_key), + ) + def test_bump_clean_versions(self): # We Test each property explicitly as the == comparator for versions # does not distinguish between prerelease or builds for equality. @@ -365,8 +395,34 @@ def test_bump_prerelease_versions(self): self.assertEqual(v.prerelease, ()) self.assertEqual(v.build, ()) + def test_truncate(self): + v = base.Version("3.2.1-pre+build") + self.assertEqual(v.truncate("build"), v) + self.assertIsNot(v.truncate("build"), v) + self.assertEqual(v.truncate("prerelease"), base.Version("3.2.1-pre")) + self.assertEqual(v.truncate("patch"), base.Version("3.2.1")) + self.assertEqual(v.truncate(), base.Version("3.2.1")) + self.assertEqual(v.truncate("minor"), base.Version("3.2.0")) + self.assertEqual(v.truncate("major"), base.Version("3.0.0")) + + def test_subclass(self): + """Custom subclasses of Version returns instances of themselves.""" + class MyVersion(base.Version): + pass + + v = MyVersion("3.2.1-pre") + subv = v.truncate() + self.assertEqual(type(subv), MyVersion) + class SpecItemTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + invalids = [ '<=0.1.1+build3', '<=0.1.1+', @@ -540,10 +596,17 @@ def test_hash(self): class CoerceTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + examples = { # Dict of target: [list of equivalents] '0.0.0': ('0', '0.0', '0.0.0', '0.0.0+', '0-'), - '0.1.0': ('0.1', '0.1+', '0.1-', '0.1.0'), + '0.1.0': ('0.1', '0.1+', '0.1-', '0.1.0', '0.01.0', '000.0001.0000000000'), '0.1.0+2': ('0.1.0+2', '0.1.0.2'), '0.1.0+2.3.4': ('0.1.0+2.3.4', '0.1.0+2+3+4', '0.1.0.2+3+4'), '0.1.0+2-3.4': ('0.1.0+2-3.4', '0.1.0+2-3+4', '0.1.0.2-3+4', '0.1.0.2_3+4'), @@ -564,9 +627,28 @@ def test_invalid(self): class SpecTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + + def assertCountEqual(self, a, b): + import collections + + self.assertEqual( + collections.Counter(a), + collections.Counter(b), + ) + examples = { '>=0.1.1,<0.1.2': ['>=0.1.1', '<0.1.2'], '>=0.1.0,!=0.1.3-rc1,<0.1.3': ['>=0.1.0', '!=0.1.3-rc1', '<0.1.3'], + '=0.1.2': ['==0.1.2'], + '>=0.1.2': ['>=0.1.2'], + '^1.2.3': ['>=1.2.3', '<2.0.0'], + '~=1.2.3': ['>=1.2.3', '<1.3.0'], } def test_parsing(self): @@ -578,9 +660,6 @@ def test_parsing(self): self.assertNotEqual(spec_list_text, spec_list) self.assertCountEqual(specs, [str(spec) for spec in spec_list]) - for spec_text in specs: - self.assertIn(str(base.SpecItem(spec_text)), repr(spec_list)) - split_examples = { ('>=0.1.1', '<0.1.2', '!=0.1.1+build1'): ['>=0.1.1', '<0.1.2', '!=0.1.1+build1'], ('>=0.1.0', '!=0.1.3-rc1,<0.1.3'): ['>=0.1.0', '!=0.1.3-rc1', '<0.1.3'], @@ -604,6 +683,16 @@ def test_parsing_split(self): ['0.1.1', '0.1.1+4'], ['0.1.1-alpha', '0.1.2-alpha', '0.1.2', '1.3.4'], ), + # 0.1.x + '==0.1.*': ( + ['0.1.1', '0.1.1+4', '0.1.0', '0.1.99'], + ['0.1.0-alpha', '0.0.1', '0.2.0'], + ), + # 1.x.x + '==1.*': ( + ['1.1.1', '1.1.0+4', '1.1.0', '1.99.99'], + ['1.0.0-alpha', '0.1.0', '2.0.0'], + ), # At least 0.1.0 with pre-releases, less than 0.1.4 excluding pre-releases, # neither 0.1.3-rc1 nor any build of that version, # not 0.1.0+b3 precisely diff --git a/tests/test_django.py b/tests/test_django.py index 5fff8a9..3361a9b 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -261,3 +261,17 @@ def test_db_interaction(self): obj2 = models.VersionModel.objects.get(pk=o2.pk) self.assertEqual(o2.version, obj2.version) + + def test_get_or_create(self): + o1, created = models.VersionModel.objects.get_or_create(version=Version('0.1.1'), spec=SimpleSpec('==0.4.3')) + self.assertTrue(created) + self.assertIsNotNone(o1.pk) + self.assertEqual(Version('0.1.1'), o1.version) + self.assertEqual(SimpleSpec('==0.4.3'), o1.spec) + + o2, created = models.VersionModel.objects.get_or_create(version=Version('0.1.1'), spec=SimpleSpec('==0.4.3')) + self.assertFalse(created) + self.assertEqual(o1, o2) + self.assertEqual(o1.pk, o2.pk) + self.assertEqual(Version('0.1.1'), o2.version) + self.assertEqual(SimpleSpec('==0.4.3'), o2.spec) diff --git a/tests/test_match.py b/tests/test_match.py index 4bf7162..6851eb2 100755 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -4,11 +4,19 @@ # This code is distributed under the two-clause BSD License. import unittest +import sys import semantic_version class MatchTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + invalid_specs = [ '', '!0.1', diff --git a/tests/test_npm.py b/tests/test_npm.py index 76cb6e2..2102cd8 100644 --- a/tests/test_npm.py +++ b/tests/test_npm.py @@ -6,11 +6,19 @@ """Test NPM-style specifications.""" import unittest +import sys from semantic_version import base class NpmSpecTests(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + examples = { # range: [matchings], [failings] '>=1.2.7': ( @@ -33,6 +41,10 @@ class NpmSpecTests(unittest.TestCase): ['1.2.3-alpha.3', '1.2.3-alpha.7', '3.4.5'], ['1.2.3-alpha.2', '3.4.5-alpha.9'], ), + '>1.2.3-alpha <1.2.3-beta': ( + ['1.2.3-alpha.0', '1.2.3-alpha.1'], + ['1.2.3', '1.2.3-beta.0', '1.2.3-bravo'], + ), '1.2.3 - 2.3.4': ( ['1.2.3', '1.2.99', '2.2.0', '2.3.4', '2.3.4+b42'], ['1.2.0', '1.2.3-alpha.1', '2.3.5'], @@ -77,6 +89,18 @@ def test_spec(self): '~0.2': '>=0.2.0 <0.3.0', '~0': '>=0.0.0 <1.0.0', '~1.2.3-beta.2': '>=1.2.3-beta.2 <1.3.0', + + # Caret ranges + '^1.2.3': '>=1.2.3 <2.0.0', + '^0.2.3': '>=0.2.3 <0.3.0', + '^0.0.3': '>=0.0.3 <0.0.4', + '^1.2.3-beta.2': '>=1.2.3-beta.2 <2.0.0', + '^0.0.3-beta': '>=0.0.3-beta <0.0.4', + '^1.2.x': '>=1.2.0 <2.0.0', + '^0.0.x': '>=0.0.0 <0.1.0', + '^0.0': '>=0.0.0 <0.1.0', + '^1.x': '>=1.0.0 <2.0.0', + '^0.x': '>=0.0.0 <1.0.0', } def test_expand(self): diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 0da679f..45d22c6 100755 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -5,11 +5,19 @@ import itertools import unittest +import sys import semantic_version class ParsingTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + invalids = [ None, '', @@ -66,6 +74,13 @@ def test_kwargs(self): class ComparisonTestCase(unittest.TestCase): + if sys.version_info[0] <= 2: + import contextlib + + @contextlib.contextmanager + def subTest(self, **kwargs): + yield + order = [ '1.0.0-alpha', '1.0.0-alpha.1', diff --git a/tox.ini b/tox.ini index 9ec0fb3..369cf12 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,25 @@ [tox] envlist = - py{34,35,36,37}-django111 - py{35,36,37}-django22 - pypy3-django{111,22} + py{37,38,39,310}-django32 + py{38,39,310,311}-django41 + pypy3-django{32} lint toxworkdir = {env:TOX_WORKDIR:.tox} [testenv] +extras = dev deps = - -rrequirements_test.txt - django111: Django>=1.11,<1.12 - django22: Django>=2.2,<2.3 + django32: Django>=3.2,<3.3 + django41: Django>=4.1,<4.2 +allowlist_externals = make whitelist_externals = make commands = make test [testenv:lint] -deps = - -rrequirements_test.txt +extras = dev +allowlist_externals = make whitelist_externals = make commands = make lint