diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f83e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv/ +venv2/ +venv3/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +.mypy_cache/ +*.egg-info/ +.installed.cfg +*.egg +.eggs/ +.pytest_cache/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml +.coverage_percent.txt + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ +.idea +.*.make_marker +README.html +envs.txt +test_build_logs/*.log diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..c852261 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,7 @@ +Changelog for pycalver +====================== + +v201809.0001-alpha +------------------ + +- Initial release diff --git a/LICENSE b/LICENSE index 182a256..ee7dd80 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 mbarkhau +Copyright (c) 2018 Manuel Barkhau (@mbarkhau) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..60f4af9 --- /dev/null +++ b/Makefile @@ -0,0 +1,160 @@ +.PHONY: setup_conda_envs install \ + test lint \ + clean rm_site_packages \ + build readme upload + + +build/.setup_conda_envs.make_marker: + conda create --name pycalver_37 python=3.7 --yes + conda create --name pycalver_36 python=3.6 --yes + conda create --name pycalver_27 python=2.7 --yes + @mkdir -p build/ + @touch build/.setup_conda_envs.make_marker + + +build/envs.txt: build/.setup_conda_envs.make_marker + @mkdir -p build/ + conda env list | grep pycalver | rev | cut -d " " -f1 | rev > build/envs.txt.tmp + mv build/envs.txt.tmp build/envs.txt + + +PYENV37 ?= $(shell bash -c "grep 37 build/envs.txt || true") +PYENV36 ?= $(shell bash -c "grep 36 build/envs.txt || true") +PYENV27 ?= $(shell bash -c "grep 27 build/envs.txt || true") +PYTHON37 ?= $(PYENV37)/bin/python +PYTHON36 ?= $(PYENV36)/bin/python +PYTHON27 ?= $(PYENV27)/bin/python + +BDIST_WHEEL_LIB3TO6 = $(shell bash -c "ls -1t dist/pycalver*py2*.whl | head -n 1") +SDIST_LIB3TO6 = $(shell bash -c "ls -1t dist/pycalver*.tar.gz | head -n 1") +DIST_WHEEL_TEST = $(shell bash -c "ls -1t test_project/dist/*py2*.whl | head -n 1") +BUILD_LOG_DIR = "test_build_logs/" +BUILD_LOG_FILE := $(shell date +"$(BUILD_LOG_DIR)%Y%m%dt%H%M%S%N.log") + + +build/.install.make_marker: setup.py build/envs.txt + $(PYTHON36) -m pip install --upgrade --quiet \ + pip setuptools wheel twine \ + flake8 mypy typing-extensions \ + rst2html5 \ + pytest pytest-cov \ + ipython pudb \ + pathlib2 click; + + $(PYTHON37) -m pip install --upgrade --quiet pip setuptools wheel; + $(PYTHON36) -m pip install --upgrade --quiet pip setuptools wheel; + $(PYTHON27) -m pip install --upgrade --quiet pip setuptools wheel; + + @mkdir -p build/ + @touch build/.install.make_marker + + +clean: + rm -f build/envs.txt + rm -f build/.setup_conda_envs.make_marker + rm -f build/.install.make_marker + + +# NOTE (mb 2018-08-23): The linter has an issue running with +# python 3.7 because some code in pycodestyle=2.3.1 +# but we have to wait for a flake8 update because +# reasons... https://github.com/PyCQA/pycodestyle/issues/728 + + +lint: build/.install.make_marker + @echo -n "lint.." + @$(PYTHON36) -m flake8 src/pycalver/ + @echo "ok" + + +mypy: build/.install.make_marker + @echo -n "mypy.." + @MYPYPATH=stubs/ $(PYTHON36) -m mypy \ + src/pycalver/ + @echo "ok" + + +test: build/.install.make_marker + @PYTHONPATH=src/:$$PYTHONPATH \ + $(PYTHON36) -m pytest \ + --cov-report html \ + --cov=pycalver \ + test/ + + +devtest: build/.install.make_marker + PYTHONPATH=src/:$$PYTHONPATH \ + $(PYTHON36) -m pytest -v \ + --cov-report term \ + --cov=pycalver \ + --capture=no \ + --exitfirst \ + test/ + + +build/.coverage_percent.txt: test + @mkdir -p build/ + @grep -oP '>[0-9]+%' htmlcov/index.html \ + | head -n 1 \ + | grep -oP '[.0-9]+' \ + > build/.coverage_percent.txt + + +README.rst: build/.coverage_percent.txt + @sed -i "s/coverage-[0-9]*/coverage-$$(cat build/.coverage_percent.txt)/" README.rst + + +build/README.html: build/.install.make_marker README.rst CHANGELOG.rst + @cat README.rst > build/.full_readme.rst + @echo "\n" >> build/.full_readme.rst + @cat CHANGELOG.rst >> build/.full_readme.rst + @$(PYENV36)/bin/rst2html5 --strict \ + build/.full_readme.rst > build/README.html.tmp + @mv build/README.html.tmp build/README.html + @echo "updated build/README.html" + + +readme: build/README.html + + +build/.src_files.txt: setup.py build/envs.txt src/pycalver/*.py + @mkdir -p build/ + @ls -l setup.py build/envs.txt src/pycalver/*.py > build/.src_files.txt.tmp + @mv build/.src_files.txt.tmp build/.src_files.txt + + +rm_site_packages: + rm -rf $(PYENV36)/lib/python3.6/site-packages/pycalver/ + rm -rf $(PYENV36)/lib/python3.6/site-packages/pycalver*.dist-info/ + rm -rf $(PYENV36)/lib/python3.6/site-packages/pycalver*.egg-info/ + rm -f $(PYENV36)/lib/python3.6/site-packages/pycalver*.egg + + +build/.local_install.make_marker: build/.src_files.txt rm_site_packages + @echo "installing pycalver.." + @$(PYTHON36) setup.py install --no-compile --verbose + @mkdir -p build/ + @$(PYTHON36) -c "import pycalver" + @echo "install completed for pycalver" + @touch build/.local_install.make_marker + + +build: build/.local_install.make_marker + @mkdir -p $(BUILD_LOG_DIR) + @echo "writing full build log to $(BUILD_LOG_FILE)" + @echo "building pycalver.." + @$(PYTHON36) setup.py bdist_wheel --python-tag=py2.py3 >> $(BUILD_LOG_FILE) + @echo "build completed for pycalver" + + +upload: build/.install.make_marker build/README.html + $(PYTHON36) setup.py bdist_wheel --python-tag=py2.py3 + $(PYENV36)/bin/twine upload $(BDIST_WHEEL_LIB3TO6) + + +setup_conda_envs: build/.setup_conda_envs.make_marker + +install: build/.install.make_marker + +run_main: + PYTHONPATH=src/:$$PYTHONPATH $(PYTHON36) -m pycalver --help diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..7775999 --- /dev/null +++ b/README.rst @@ -0,0 +1,546 @@ +PyCalVer: Automatic CalVer Versioning for Python Packages +========================================================= + +PyCalVer is a very simple versioning system, +which is compatible with python packaging software +( +`setuptools `_, +`PEP440 `_ +). + +.. start-badges + +.. list-table:: + :stub-columns: 1 + + * - package + - | |license| |wheel| |pyversions| |pypi| |version| + * - tests + - | |travis| |mypy| |coverage| + +.. |travis| image:: https://api.travis-ci.org/mbarkhau/pycalver.svg?branch=master + :target: https://travis-ci.org/mbarkhau/pycalver + :alt: Build Status + +.. |mypy| image:: http://www.mypy-lang.org/static/mypy_badge.svg + :target: http://mypy-lang.org/ + :alt: Checked with mypy + +.. |coverage| image:: https://img.shields.io/badge/coverage-86%25-green.svg + :target: https://travis-ci.org/mbarkhau/pycalver + :alt: Code Coverage + +.. |license| image:: https://img.shields.io/pypi/l/pycalver.svg + :target: https://github.com/mbarkhau/pycalver/blob/master/LICENSE + :alt: MIT License + +.. |pypi| image:: https://img.shields.io/pypi/v/pycalver.svg + :target: https://pypi.python.org/pypi/pycalver + :alt: PyPI Version + +.. |version| image:: https://img.shields.io/badge/CalVer-v201809.0001--beta-blue.svg + :target: https://calver.org/ + :alt: CalVer v201809.0001-beta + +.. |wheel| image:: https://img.shields.io/pypi/wheel/pycalver.svg + :target: https://pypi.org/project/pycalver/#files + :alt: PyPI Wheel + +.. |pyversions| image:: https://img.shields.io/pypi/pyversions/pycalver.svg + :target: https://pypi.python.org/pypi/pycalver + :alt: Supported Python Versions + + +The PyCalVer package provides the ``pycalver`` command and +module to generate version strings which follow the format: +``v{calendar_version}.{build_number}[-{tag}]`` + +Some examples: + + +.. code-block:: + + v201711.0001-alpha + v201712.0027-beta + v201801.0031 + v201801.0032-post + ... + v202207.18133 + v202207.18134 + + +The ``pycalver bump`` command will parse specified/configured +files for such strings and rewrite them with an updated version +string. + +The format accepted by PyCalVer can be parsed with this regular +expression: + + +.. code-block:: python + + import re + + pycalver_re = re.compile(r""" + # https://regex101.com/r/fnj60p/9 + \b + (?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d{4,}) + ) + (?: + \- # "-" release prefix + (?P + alpha|beta|dev|rc|post + ) + )? + )(?:\s|$) + """, flags=re.VERBOSE) + + version_str = "v201712.0001-alpha" + version_info = pycalver_re.match(version_str).groupdict() + + assert version_info == { + "version" : "v201712.0001-alpha", + "calver" : "v201712", + "year" : "2017", + "month" : "12", + "build" : "0001", + "release" : "alpha", + } + + version_str = "v201712.0033" + version_info = pycalver_re.match(version_str).groupdict() + + assert version_info == { + "version" : "v201712.0033", + "calver" : "v201712", + "year" : "2017", + "month" : "12", + "build" : "0033", + "release" : None, + } + + +Usage +----- + +Before we look at project setup, we can simply install and test +by passing a version string to ``pycalver bump``. + + +.. code-block:: bash + + $ pip install pycalver + $ pycalver bump v201801.0033-beta + v201809.0034-beta + + +The CalVer component is set to the current year and month, the +build number is incremented by one and the optional release tag +is preserved as is, unless specified otherwise via the +``--release=`` parameter. + +To setup a project, add the following lines to your ``setup.cfg`` + + +.. code-block:: ini + + [pycalver] + current_version = v201809.0001-beta + commit = True + tag = True + + [pycalver:file:setup.cfg] + patterns = + current_version = {version} + + [pycalver:file:setup.py] + patterns = + version="{pep440_version}", + + [pycalver:file:src/myproject.py] + patterns = + __version__ = "{version}" + + [pycalver:file:README.rst] + patterns = + badge/CalVer-{calver}{build}-{release}-blue.svg + :alt: CalVer {version} + + +The above setup.cfg file is very explicit, and can be shortened quite a bit. + + +.. code-block:: ini + + [pycalver] + current_version = v201809.0001-beta + commit = True + tag = True + + [pycalver:file:setup.cfg] + [pycalver:file:setup.py] + [pycalver:file:src/myproject.py] + [pycalver:file:README.rst] + patterns = + badge/CalVer-{calver}{build}-{release}-blue.svg + :alt: CalVer {version} + + +This makes use of the default ``patterns = {version}``, which +will replace all occurrences of a PyCalVer version string with +the updated ``current_version``. + +Now we can call ``pycalver bump`` to bump all occurrences of +version strings in these files. Normally this will change local +files, but the ``--dry`` flag will instead display a diff of the +changes that would be applied. + + +.. code-block:: bash + + $ pycalver show + Current Version: v201809.0001-beta + PEP440 Version: 201809.1b0 + + $ pycalver bump --dry + TODO + Don't forget to git push --tags + + + +Other Versioning Software +------------------------- + +This project is very similar to bumpversion, upon which it is +partially based, but since the PyCalVer version strings can be +generated automatically, usage is quite a bit more simple. Users +do not have to deal with parsing and generating version strings. +Most of the interaction that users will have is reduced to two +commands: + + +.. code-block:: bash + + $ pycalver bump + TODO: Output + + +More rarely, when changing the release type: + +.. code-block:: bash + + $ pycalver bump --release beta + TODO: Output + + $ pycalver bump --release final + TODO: Output + + +Some Details +------------ + + - Version numbers are for public releases. For the purposes of + development of the project itself, reference VCS branches and + commit ids are more appropriate. + - There should be only one person or system responsible for + updating the version number at the time of release, otherwise + the same version number may be generated for different builds. + - Lexeographical order is + + +Canonical PyCalVer version strings can be parsed with this +regular expression: + + +These are the full version strings, for public announcements and +conversations it will often be sufficient to refer simply to +``v201801``, by which the most recent ``post`` release build of +that month is meant. + + + + version_str = "v201712.0027-beta" + version_dict = pycalver_re.match("v201712.0027-beta").groupdict() + import pkg_resources # from setuptools + version = pkg_resources.parse_version(version_str) + -- + + In [2]: version_dict + {'year': '2017', 'month': '12', 'build_nr': '0027', 'tag': 'beta'} + >>> str(version) + 201712.27b0 + + +Lexical Ids +----------- + +Most projects will be served perfectly well by the default four +digit zero padded build number. Depending on your build system +however, you may get into higher build numbers. Since build +numbers have no semantic meaning (beyond larger = later/newer), +they are incremented in a way that preserves lexical ordering as +well as numerical order. Examples will perhaps illustrate more +clearly. + +.. code-block:: python + + "0001" + "0002" + "0003" + ... + "0999" + "11000" + "11001" + ... + "19998" + "19999" + "220000" + "220001" + +What is happening here is that the left-most digit is incremented +early, whenever the left-most digit changes. The formula is very simple: + +.. code-block:: python + + prev_id = "0999" + next_id = str(int(prev_id, 10) + 1) # "1000" + if prev_id[0] != next_id[0]: # "0" != "1" + next_id = str(int(next_id, 10) * 11) # 1000 * 11 = 11000 + + +In practice you can just ignore the left-most digit, in case you +do want to read something into the semantically meaningless +build number. + + +Realities of Verion Numbers +--------------------------- + +Nobody knows what the semantics of a version number are, because +nobody can guarantee that a given release adheres to whatever +convention one would like to imbibe it with. Lets just keep things +simple. + + - Version numbers should be recognizable as such, that's what + the "v" prefix does. + - A number like 201808 is recognizable to many as a number + derived from a calendar. + - alpha, beta are common parlance indicating software which is + still under development. + +Some additional constraints are applied to conform with PEP440 + + +Should I use PyCalVer for my Project? +------------------------------------- + +If your project is 1. not useful by itself, but only when used +by other software, 2. has a finite scope/a definition of "done", +3. your project has CI, a test suite with and decent code +coverage, then PyCalVer is worth considering. +You release at most once per month. + + +Marketing/Vanity +---------------- + +Quotes from http://sedimental.org/designing_a_version.html + + +Rational +-------- + +PyCalVer is opinionated software. This keeps things simple, +when the opintions match yours, but makes it useless for +everybody else. + +The less machine parsable semantics you put in your version +string, the better. The ideal would be to only have a single +semantic: newer == better. + +Some projects depend recursively on hundreds of libraries, so +compatability issues generated by your project can be a heavy +burdon on thousands of users; users who learn of the existance +of your library for the first time in the form of a stacktrace. +PyCalVer is for projects that are comitted to and can maintain +backward compatability. Newer versions are always better, +updates are always safe, an update won't break things, and if it +does, the maintainer's hair is on fire and they will publish a +new release containing a fix ASAP. + +Ideally, your user can just declare your library as a +dependency, without any extra version qualifier, and never have +to think about it again. If you do break something by accident, +their remedy is not to change their code, but to temporarily pin +an earlier version, until your bugfix release is ready. + +PyCalVer is for projects which are the mundane but dependable +foundations of other big shiny projects, which get to do their +big and exciting 2.0 major releases. + + +Breaking Things is a Big Deal +----------------------------- + +Using an increment in a version string to express that a release +may break client code is not tennable. A developer cannot be +expected to think about how their code may or may not break as a +consequence of your decision to rename some functions. As the +author of any software, there is a great temptation to move fast +and break things. This is great when no other software depends +on yours. If something breaks, you jump up and fix it. The story +is quite different even when only a few dozen people depend on +your software. + + +The less the users of your library have to know about your +project, the better. The less they have to deal with issues +of compatability, the better. SemVer can be overly specifc +for some kinds of projects. If you are writing a library +and you have a commitment to backward compatability + +PyCalVer version strings can be parsed according to PEP440 +https://www.python.org/dev/peps/pep-0440/ + + +A Word on Marketing +------------------- + +This setup of expectations for users can go one of two ways, + +We use version numbers to communicate between the authors +of software and its users. For users of libraries Particularly +for libraries, it pays to keep things as simple as possible for +your human users. + + +Commitment to Compatability +--------------------------- + +Software projects can depend on many libraries. Consider that one +package introducing a breaking change is enough to mess up your +day. Especially in the case of libraries, your users should be +able to write code that uses it and not have that code break at +any point in the future. Users cannot be asked to keep track of +all the changes to every little library that they use. + +PyCalVer is explicitly non semantic. A PyCalVer version number does +not express anything about + + - Don't ever break things. When users depend on your + software, backward compatability matters and the way to + express backward incompatible changes is not to bump a + version number, but to change the package name. A change + in the package name clearly communicates that a user must + change their code so that it will work with the changed + API. Everybody who does not have the bandwith for those + changes, doesn't even have to be aware of your new + release. + + - When you do break something, that should be considered a + bug that has to be fixed as quickly as possible in a new + version. It should always be safe for a user to update + their dependencies. If something does break, users have to + temporarilly pin an older (known good) version, or update + to a newer fixed version. + + - Version numbers should not require a parser (present + package excluded of course). A newer version number should + always be lexeographically greater than an older one. + TODO: + https://setuptools.readthedocs.io/en/latest/setuptools.html#specifying-your-project-s-version + + +The main component of the version number is based on the +calendar date. This is allows you to show your commitment (or +lack thereof) to the maintenance of your libarary. It also +allows users to see at a glance that their dependency might be +out of date. In this versioning scheme it is completely +reasonable to bump the version number without any changes, +simply to express to your users, that you are still actively +maintaining the software and that it is in a known good state. + + +For a much more detailed exposition of CalVer, see +http://sedimental.org/designing_a_version.html +https://calver.org/ + +from pkg_resources import parse_version + + +The Life of a Library +--------------------- + +mylib v201711.001-alpha # birth of a project (in alpha) +mylib v201711.002-alpha # new features (in alpha) +mylib v201712.003-beta # bugfix release (in beta) +mylib v201712.004-rc # release candidate +mylib v201712.005 # stable release +mylib v201712.006 # stable bugfix release + +mylib2 v201712.007-beta # breaking change (new package name!) +mylib2 v201801.008-beta # new features (in beta) +mylib2 v201801.009 # stable release + +mylib v201802.007 # security fix for legacy version +mylib2 v201802.010 # security fix + +mylib2 v202604.9900 # threshold for four digit build numbers +mylib2 v202604.9901 # still four digits in the same month +mylib2 v202604.9911 # last build number with four digits +mylib2 v202605.09912 # build number zero padding added with date turnover +mylib2 v202605.09913 # stable release + +mylib2 v203202.16051-rc # release candidate +mylib2 v203202.16052 # stable release + + ... + v202008.500 # 500 is the limit for four digit build numbers, but + v202008.508 # zero padding is added only after the turnover to + v202009.0509 # a new month, so that lexical ordering is preserved. + +The date portion of the version, gives the user an indication of +how up their dependency is, whether or not a project is still +being maintained. + +The build number, gives the user an idea of the maturity of the +project. A project which has been around long enough to produce +hundreds of builds, might be considered mature, or at least a +project that is only on build number 10, is probably still in +early development. + + +FAQ +--- + +Q: "So you're trying to tell me I need to create a whole new +package every time I introduce a introduce a breaking change?!". + +A: First of all, what the hell are you doing? Secondly, YES! +Let's assume your little package has even just 100 users. Do you +have any idea about the total effort that will be expended +because you decided it would be nice to change the name of a +function? It is completely reasonable introduce that the +friction for the package author when the price to users is +orders of magnitude larger. + + +1801 + +https://calver.org/ + +I have given up on the idea that version numbers express +anything about changes made between versions. Trying to +express such information assumes 1. that the author of a package +is aware of how a given change needs to be reflected in a +version number and 2. that users and packaging softare correctly +parse that meaning. When I used semantic versioning, I realized that +the major version number of my packages would never change, because I don't think breaking changes should ever be One of the biggest offenses expres diff --git a/license.header b/license.header new file mode 100644 index 0000000..2628705 --- /dev/null +++ b/license.header @@ -0,0 +1,10 @@ +Individual files contain the following tag instead of the full license text. + + This file is part of the pycalver project + https://github.com/mbarkhau/pycalver + + (C) 2018 Manuel Barkhau (@mbarkhau) + SPDX-License-Identifier: MIT + +This enables machine processing of license information based on the SPDX +License Identifiers that are here available: https://spdx.org/licenses/ diff --git a/scratch.py b/scratch.py new file mode 100644 index 0000000..8283627 --- /dev/null +++ b/scratch.py @@ -0,0 +1,109 @@ +from pkg_resources import parse_version +import re + +canonical_version_re = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d{3,}) + ) +)(?:\s|$) +""", flags=re.VERBOSE) + + +full_version_re = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d+) + ) + (?: + \- # "-" tag prefix + (?Pa|alpha|b|beta|dev|c|rc|pre|preview|post) + (?P\d*) + )? +)(?:\s|$) +""", flags=re.VERBOSE) + +versions = [ + "v201711.0001-alpha", + "v201711.0002-alpha", + "v201712.0003-beta3", + "v201712.0004-preview", + "v201712.0005", + "v201712.0006", + "v201712.0007-beta", + "v201801.0008-beta", + "v201801.0008-dev", + "v201801.0009", + "v201802.0010", + "v201802.0007", + "v201904.0050", + "v201905.0051", + "v201905.0052", + "v201712.0027-beta1", + "v201712.beta-0027", + "v201712.post-0027", + "v201712.post-0027", +] + +for vstr in versions: + v = parse_version(vstr) + print(vstr.ljust(20), repr(v).ljust(30), int(v.is_prerelease)) + v = full_version_re.match(vstr) + print("\t", v and v.groupdict()) + v = canonical_version_re.match(vstr) + print("\t", v and v.groupdict()) + +a = "v201711.beta-0001" +b = "v201711.0002-beta" +c = "v201711.0002" +d = "0.9.2" + +va = parse_version(a) +vb = parse_version(b) +vc = parse_version(c) +vd = parse_version(d) + +print(a, repr(va)) +print(b, repr(vb)) +print(c, repr(vc)) +print(d, repr(vd), vd < vc) + + +# https://regex101.com/r/fnj60p/3 +pycalver_re = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + . # "." build nr prefix + (?P\d{4,}) + ) +)(?:\s|$) +""", flags=re.VERBOSE) + +print(pycalver_re.match("v201712.0027").groupdict()) + +print(repr(parse_version("v201712.0027"))) +print(repr(parse_version("v201712.0027"))) +print(repr(parse_version("v201712.0027"))) +print(repr(parse_version("v201712") == parse_version("v201712.0"))) + +print("v201712.0027" > "v201712.0028") diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8d593ad --- /dev/null +++ b/setup.cfg @@ -0,0 +1,35 @@ +[flake8] +ignore = E203 +max-line-length = 110 +exclude = .git,__pycache__,.eggs/,dist/,.mypy_cache + +[mypy] +check_untyped_defs = True +disallow_untyped_calls = True +follow_imports = silent +strict_optional = True + +[bdist_wheel] +universal = 1 + +[pycalver] +current_version = v201808.0001-beta +commit = True +tag = True + +[pycalver:file:setup.cfg] +patterns = + current_version = {version} + +[pycalver:file:setup.py] +patterns = + __version__ = "{version}" + +[pycalver:file:src/pycalver/__init__.py] +patterns = + __version__ = "{version}" + +[pycalver:file:README.rst] +patterns = + badge/CalVer-{calver}{build}-{release}-blue.svg + :alt: CalVer {version} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e74cdad --- /dev/null +++ b/setup.py @@ -0,0 +1,80 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (mbarkhau@gmail.com) +# SPDX-License-Identifier: MIT + +import os +import sys +import setuptools + + +def project_path(filename): + dirpath = os.path.abspath(os.path.dirname(__file__)) + return os.path.join(dirpath, filename) + + +def read(filename): + with open(project_path(filename), mode="rb") as fh: + return fh.read().decode("utf-8") + + +packages = setuptools.find_packages(project_path("src")) +package_dir = {"": "src"} + + +if any(arg.startswith("bdist") for arg in sys.argv): + import lib3to6 + package_dir = lib3to6.fix(package_dir) + + +long_description = ( + read("README.rst") + + "\n\n" + + read("CHANGELOG.rst") +) + + +setuptools.setup( + name="pycalver", + license="MIT", + author="Manuel Barkhau", + author_email="mbarkhau@gmail.com", + url="https://github.com/mbarkhau/pycalver", + version="201809.1a0", + + description="CalVer versioning for python projects", + long_description=long_description, + long_description_content_type="text/x-rst", + + packages=packages, + package_dir=package_dir, + zip_safe=True, + install_requires=["typing", "click", "setuptools"], + setup_requires=["lib3to6==v201809.0017-alpha"], + entry_points=''' + [console_scripts] + pycalver=pycalver.__main__:cli + ''', + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Other Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/src/pycalver/__init__.py b/src/pycalver/__init__.py new file mode 100644 index 0000000..f042c95 --- /dev/null +++ b/src/pycalver/__init__.py @@ -0,0 +1,11 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import os + +__version__ = "v201809.0001-beta" + +DEBUG = os.environ.get("PYCALVER_DEBUG", "0") == "1" diff --git a/src/pycalver/__main__.py b/src/pycalver/__main__.py new file mode 100644 index 0000000..9aa0f78 --- /dev/null +++ b/src/pycalver/__main__.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import io +import os +import sys +import click +import logging +import typing as typ + +from . import DEBUG +from . import vcs +from . import parse +from . import config +from . import version +from . import rewrite + + +log = logging.getLogger("pycalver.__main__") + + +def _init_loggers(verbose: bool) -> None: + if DEBUG: + log_formatter = logging.Formatter('%(levelname)s - %(name)s - %(message)s') + log_level = logging.DEBUG + elif verbose: + log_formatter = logging.Formatter('%(levelname)s - %(message)s') + log_level = logging.INFO + else: + log_formatter = logging.Formatter('%(message)s') + log_level = logging.WARNING + + loggers = [log, vcs.log, parse.log, config.log, rewrite.log, version.log] + + for logger in loggers: + if len(logger.handlers) == 0: + ch = logging.StreamHandler(sys.stderr) + ch.setFormatter(log_formatter) + logger.addHandler(ch) + logger.setLevel(log_level) + + log.debug("Loggers initialized.") + + +@click.group() +def cli(): + """parse and update project versions automatically.""" + + +@cli.command() +def show() -> None: + _init_loggers(verbose=False) + + cfg = config.parse() + if cfg is None: + return + + print(f"Current Version: {cfg['current_version']}") + print(f"PEP440 Version: {cfg['pep440_version']}") + + +@cli.command() +@click.option( + "--dry", + default=False, + is_flag=True, + help="Display diff of changes, don't rewrite files.", +) +def init(dry: bool) -> None: + """Initialize [pycalver] configuration in setup.cfg""" + _init_loggers(verbose=False) + + cfg = config.parse() + if cfg: + log.error("Configuration already initialized in setup.cfg") + return + + cfg_lines = config.default_config_lines() + + if dry: + print("Exiting because of '--dry'. Would have written to setup.cfg:") + print("\n " + "\n ".join(cfg_lines)) + return + + if os.path.exists("setup.cfg"): + cfg_content = "\n" + "\n".join(cfg_lines) + with io.open("setup.cfg", mode="at", encoding="utf-8") as fh: + fh.write(cfg_content) + print("Updated setup.cfg") + else: + cfg_content = "\n".join(cfg_lines) + with io.open("setup.cfg", mode="at", encoding="utf-8") as fh: + fh.write(cfg_content) + print("Created setup.cfg") + + +@cli.command() +@click.option( + "--verbose", + default=False, + is_flag=True, + help="Log applied changes to stderr", +) +@click.option( + "--dry", + default=False, + is_flag=True, + help="Display diff of changes, don't rewrite files.", +) +@click.option( + "--release", + default=None, + metavar="", + help="Override release name of current_version", +) +def bump(verbose: bool, dry: bool, release: typ.Optional[str] = None) -> None: + _init_loggers(verbose) + if release and release not in parse.VALID_RELESE_VALUES: + log.error(f"Invalid argument --release={release}") + log.error(f"Valid arguments are: {', '.join(parse.VALID_RELESE_VALUES)}") + return + + cfg = config.parse() + + if cfg is None: + log.error("Unable to parse pycalver configuration from setup.cfg") + return + + old_version = cfg["current_version"] + new_version = version.bump(old_version, release=release) + + log.info(f"Old Version: {old_version}") + log.info(f"New Version: {new_version}") + + matches: typ.List[parse.PatternMatch] + for filepath, patterns in cfg["file_patterns"].items(): + with io.open(filepath, mode="rt", encoding="utf-8") as fh: + content = fh.read() + lines = content.splitlines() + matches = parse.parse_patterns(lines, patterns) + for m in matches: + print(m) diff --git a/src/pycalver/config.py b/src/pycalver/config.py new file mode 100644 index 0000000..83d1aa6 --- /dev/null +++ b/src/pycalver/config.py @@ -0,0 +1,122 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import io +import os +import configparser +import pkg_resources +import typing as typ +import datetime as dt + +import logging + +from .parse import PYCALVER_RE + +log = logging.getLogger("pycalver.config") + + +def parse(config_file="setup.cfg") -> typ.Optional[typ.Dict[str, typ.Any]]: + if not os.path.exists(config_file): + log.error("File not found: setup.cfg") + return None + + cfg_parser = configparser.RawConfigParser("") + with io.open(config_file, mode="rt", encoding="utf-8") as fh: + cfg_parser.readfp(fh) + + if "pycalver" not in cfg_parser: + log.error("setup.cfg does not contain a [pycalver] section.") + return None + + cfg = dict(cfg_parser.items("pycalver")) + + if "current_version" not in cfg: + log.error("setup.cfg does not have 'pycalver.current_version'") + return None + + current_version = cfg["current_version"] + if PYCALVER_RE.match(current_version) is None: + log.error(f"setup.cfg 'pycalver.current_version is invalid") + log.error(f"current_version = {current_version}") + return None + + cfg["pep440_version"] = str(pkg_resources.parse_version(current_version)) + + cfg["tag"] = cfg.get("tag", "").lower() in ("yes", "true", "1", "on") + cfg["commit"] = cfg.get("commit", "").lower() in ("yes", "true", "1", "on") + + cfg["file_patterns"] = {} + + for section_name in cfg_parser.sections(): + if not section_name.startswith("pycalver:file:"): + continue + + filepath = section_name.split(":", 2)[-1] + if not os.path.exists(filepath): + log.error(f"No such file: {filepath} from {section_name} in setup.cfg") + return None + + section = dict(cfg_parser.items(section_name)) + + if "patterns" in section: + cfg["file_patterns"][filepath] = [ + line.strip() + for line in section["patterns"].splitlines() + if line.strip() + ] + else: + cfg["file_patterns"][filepath] = ["{version}", "{pep440_version}"] + + if not cfg["file_patterns"]: + cfg["file_patterns"]["setup.cfg"] = ["{version}", "{pep440_version}"] + + log.debug(f"Config Parsed: {cfg}") + + return cfg + + +def default_config_lines() -> typ.List[str]: + initial_version = dt.datetime.now().strftime("v%Y%m.0001-dev") + + cfg_lines = [ + "[pycalver]", + f"current_version = {initial_version}", + "commit = True", + "tag = True", + "", + "[pycalver:file:setup.cfg]", + "patterns = ", + " current_version = {version}", + "", + ] + + if os.path.exists("setup.py"): + cfg_lines.extend([ + "[pycalver:file:setup.py]", + "patterns = ", + " \"{version}\"", + " \"{pep440_version}\"", + "", + ]) + + if os.path.exists("README.rst"): + cfg_lines.extend([ + "[pycalver:file:README.rst]", + "patterns = ", + " {version}", + " {pep440_version}", + "", + ]) + + if os.path.exists("README.md"): + cfg_lines.extend([ + "[pycalver:file:README.md]", + " {version}", + " {pep440_version}", + "", + ]) + + return cfg_lines diff --git a/src/pycalver/lex_id.py b/src/pycalver/lex_id.py new file mode 100644 index 0000000..9f70c4a --- /dev/null +++ b/src/pycalver/lex_id.py @@ -0,0 +1,137 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +""" +This is a simple scheme for numerical ids which are ordered both +numerically and lexically. + +Throughout the sequence this expression remains true, whether you +are dealing with integers or strings: + + older_id < newer_id + +The left most character/digit is only used to maintain lexical +order, so that the position in the sequence is maintained in the +remaining digits. + + sequence_pos = int(idval[1:], 10) + +lexical sequence_pos +0 0 +11 1 +12 2 +... +19 9 +220 20 +221 21 +... +298 98 +299 99 +3300 300 +3301 301 +... +3998 998 +3999 999 +44000 4000 +44001 4001 +... +899999998 99999998 +899999999 99999999 +9900000000 900000000 +9900000001 900000001 +... +9999999998 999999998 +9999999999 999999999 # maximum value + +You can add leading zeros to delay the expansion and/or increase +the maximum possible value. + +lexical sequence_pos +0001 1 +0002 2 +0003 3 +... +0999 999 +11000 1000 +11001 1001 +11002 1002 +... +19998 9998 +19999 9999 +220000 20000 +220001 20001 +... +899999999998 99999999998 +899999999999 99999999999 +9900000000000 900000000000 +9900000000001 900000000001 +... +9999999999998 999999999998 +9999999999999 999999999999 # maximum value + +This scheme is useful when you just want an ordered sequence of +numbers, but the numbers don't have any particular meaning or +arithmetical relation. The only relation they have to each other +is that numbers generated later in the sequence are greater than +ones generated earlier. +""" + + +MINIMUM_ID = "0" + + +def next_id(prev_id: str) -> str: + num_digits = len(prev_id) + _prev_id = int(prev_id, 10) + _next_id = int(_prev_id) + 1 + next_id = f"{_next_id:0{num_digits}}" + if prev_id[0] != next_id[0]: + next_id = str(_next_id * 11) + return next_id + + +def ord_val(lex_id: str) -> int: + if len(lex_id) == 1: + return int(lex_id, 10) + return int(lex_id[1:], 10) + + +def main(): + _curr_id = "01" + print(f"{'lexical':<13} {'numerical':>12}") + + while True: + print(f"{_curr_id:<13} {ord_val(_curr_id):>12}") + _next_id = next_id(_curr_id) + + assert _curr_id < next_id + assert int(_curr_id, 10) < int(next_id, 10) + assert ord_val(_curr_id) < ord_val(next_id) + + # while next_id.startswith("0") and int(next_id) < 1000: + # _next_id = next_id(_next_id) + + if next_id.count("9") == len(next_id): + # all nines, we're done + print(f"{next_id:<13} {ord_val(next_id):>12}") + break + + if _next_id[0] != _curr_id[0] and len(_curr_id) > 1: + print(f"{_next_id:<13} {ord_val(_next_id):>12}") + _next_id = next_id(_next_id) + print(f"{_next_id:<13} {ord_val(_next_id):>12}") + _next_id = next_id(_next_id) + + print("...") + + # skip ahead + _next_id = _next_id[:1] + "9" * (len(_next_id) - 2) + "8" + + _curr_id = _next_id + + +if __name__ == '__main__': + main() diff --git a/src/pycalver/parse.py b/src/pycalver/parse.py new file mode 100644 index 0000000..8568bf2 --- /dev/null +++ b/src/pycalver/parse.py @@ -0,0 +1,94 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import re +import io +import os +import sys +import logging +import typing as typ +import datetime as dt + +from pkg_resources import parse_version + +from . import lex_id + +log = logging.getLogger("pycalver.parse") + + +VALID_RELESE_VALUES = ("alpha", "beta", "dev", "rc", "post") + + +PYCALVER_RE: typ.re.Pattern[str] = re.compile(r""" +\b +(?P + (?P + v # "v" version prefix + (?P\d{4}) + (?P\d{2}) + ) + (?: + \. # "." build nr prefix + (?P\d{4,}) + ) + (?: + \- # "-" release prefix + (?P + alpha|beta|dev|rc|post + ) + )? +)(?:\s|$) +""", flags=re.VERBOSE) + + +RE_PATTERN_PARTS = { + "pep440_version" : r"\d{6}\.\d+(a|b|dev|rc|post)?\d*", + "version" : r"v\d{6}\.\d{4,}\-(?:alpha|beta|dev|rc|post)", + "calver" : r"v\d{6}", + "build" : r"\.\d{4,}", + "release" : r"(\-(?:alpha|beta|dev|rc|post))?", +} + + +class PatternMatch(typ.NamedTuple): + + lineno : int + line : str + pattern : str + span : typ.Tuple[int, int] + match : str + + +MaybeMatch = typ.Optional[typ.re.Match[str]] +PyCalVerInfo = typ.Dict[str, str] + + +def iter_pattern_matches(lines: typ.List[str], pattern: str) -> typ.Iterable[PatternMatch]: + # The pattern is escaped, so that everything besides the format + # string variables is treated literally. + pattern_re = re.compile( + pattern + .replace("\\", "\\\\") + .replace("-", "\\-") + .replace(".", "\\.") + .replace("+", "\\+") + .replace("*", "\\*") + .replace("[", "\\[") + .replace("(", "\\(") + .format(**RE_PATTERN_PARTS) + ) + for i, line in enumerate(lines): + match = pattern_re.search(line) + if match: + lineno = i + 1 + yield PatternMatch(lineno, line, pattern, match.span(), match.group(0)) + + +def parse_patterns(lines: typ.List[str], patterns: typ.List[str]) -> typ.List[PatternMatch]: + all_matches: typ.List[PatternMatch] = [] + for pattern in patterns: + all_matches.extend(iter_pattern_matches(lines, pattern)) + return all_matches diff --git a/src/pycalver/rewrite.py b/src/pycalver/rewrite.py new file mode 100644 index 0000000..f9649b8 --- /dev/null +++ b/src/pycalver/rewrite.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import logging +import difflib + +log = logging.getLogger("pycalver.rewrite") + + +def rewrite_file(file: str, pattern: str, dry=False) -> None: + difflib.unified_diff( + file_content_before.splitlines(), + file_content_after.splitlines(), + lineterm="", + fromfile="a/" + file, + tofile="b/" + file, + ) diff --git a/src/pycalver/vcs.py b/src/pycalver/vcs.py new file mode 100644 index 0000000..42ce2da --- /dev/null +++ b/src/pycalver/vcs.py @@ -0,0 +1,111 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT +# +# pycalver/vcs.py (this file) is based on code from the +# bumpversion project: https://github.com/peritus/bumpversion +# MIT License - (C) 2013-2014 Filip Noetzel + +import os +import logging +import tempfile +import subprocess as sp + + +log = logging.getLogger("pycalver.vcs") + + +class WorkingDirectoryIsDirtyException(Exception): + + def __init__(self, message): + self.message = message + + +class BaseVCS: + + @classmethod + def commit(cls, message): + f = tempfile.NamedTemporaryFile("wb", delete=False) + f.write(message.encode("utf-8")) + f.close() + cmd = cls._COMMIT_COMMAND + [f.name] + env_items = list(os.environ.items()) + [(b"HGENCODING", b"utf-8")] + sp.check_output(cmd, env=dict(env_items)) + os.unlink(f.name) + + @classmethod + def is_usable(cls): + try: + return sp.call( + cls._TEST_USABLE_COMMAND, + stderr=sp.PIPE, + stdout=sp.PIPE, + ) == 0 + except OSError as e: + if e.errno == 2: + # mercurial is not installed then, ok. + return False + raise + + @classmethod + def assert_nondirty(cls): + status_output = sp.check_output(cls._STATUS_COMMAND) + lines = [ + line.strip() + for line in status_output.splitlines() + if not line.strip().startswith(b"??") + ] + + if lines: + cleaned_output = b"\n".join(lines) + cls_name = cls.__name__ + raise WorkingDirectoryIsDirtyException( + f"{cls_name} working directory is not clean:\n{cleaned_output}" + ) + + +class Git(BaseVCS): + + _TEST_USABLE_COMMAND = ["git", "rev-parse", "--git-dir"] + _COMMIT_COMMAND = ["git", "commit", "-F"] + _STATUS_COMMAND = ["git", "status", "--porcelain"] + + @classmethod + def tag(cls, name): + sp.check_output(["git", "tag", name]) + + +class Mercurial(BaseVCS): + + _TEST_USABLE_COMMAND = ["hg", "root"] + _COMMIT_COMMAND = ["hg", "commit", "--logfile"] + _STATUS_COMMAND = ["hg", "status", "-mard"] + + @classmethod + def tag(cls, name): + sp.check_output(["hg", "tag", name]) + + +VCS = [Git, Mercurial] + + +def get_vcs(allow_dirty=False): + for vcs in VCS: + if not vcs.is_usable(): + continue + + if not allow_dirty: + try: + vcs.assert_nondirty() + except WorkingDirectoryIsDirtyException as e: + log.warn( + f"{e.message}\n\n" + f"Use --allow-dirty to override this if you know what you're doing." + ) + raise + + return vcs + + return None diff --git a/src/pycalver/version.py b/src/pycalver/version.py new file mode 100644 index 0000000..53e53b7 --- /dev/null +++ b/src/pycalver/version.py @@ -0,0 +1,71 @@ +# This file is part of the pycalver project +# https://github.com/mbarkhau/pycalver +# +# (C) 2018 Manuel Barkhau (@mbarkhau) +# SPDX-License-Identifier: MIT + +import logging +import datetime as dt + +from . import lex_id +log = logging.getLogger("pycalver.version") + + +def current_calver() -> str: + return dt.datetime.utcnow().strftime("v%Y%m") + + +def bump(old_version: str, release: str=None) -> str: + # old_version is assumed to be a valid calver string, + # validated in pycalver.config.parse. + + old_calver, rest = old_version.split(".") + old_build, old_release = rest.split("-") + + new_calver = current_calver() + new_build = lex_id.next_id(old_build) + if release is None: + # preserve existing release + new_release = old_release + else: + new_release = release + + new_version = new_calver + "." + new_build + if new_release: + new_version += "-" + new_release + return new_version + + +def incr_version(old_version: str, *, tag: str="__sentinel__") -> str: + maybe_match: MaybeMatch = VERSION_RE.search(old_version) + + if maybe_match is None: + raise ValueError(f"Invalid version string: {old_version}") + + prev_version_info: PyCalVerInfo = maybe_match.groupdict() + + prev_calver: str = prev_version_info["calver"] + next_calver: str = current_calver() + + prev_build: str = prev_version_info["build"] + if prev_calver > next_calver: + log.warning( + f"'incr_version' called with '{old_version}', " + + f"which is from the future, " + + f"maybe your system clock is out of sync." + ) + next_calver = prev_calver # leave calver as is + + next_build = lex_id.next_id(prev_build) + new_version = f"{next_calver}.{next_build}" + if tag != "__sentinel__": + if tag is None: + pass # tag explicitly ignored/removed + else: + new_version += "-" + tag + elif "tag" in prev_version_info: + # preserve previous tag + new_version += "-" + prev_version_info["tag"] + + assert old_version < new_version, f"{old_version} {new_version}" + return new_version diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 0000000..bfcea44 --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,30 @@ +import datetime as dt +import pycalver.version + + +def test_calver(): + import random + + first_version_str = "v201808.0001-dev" + padding = len(first_version_str) + 3 + version_str = first_version_str + + def _current_calver() -> str: + _current_calver.delta += dt.timedelta(days=int(random.random() * 5)) + + return (dt.datetime.utcnow() + _current_calver.delta).strftime("v%Y%m") + + _current_calver.delta = dt.timedelta(days=1) + + global current_calver + current_calver = _current_calver + + for i in range(1050): + version_str = incr_version(version_str, tag=random.choice([ + None, "alpha", "beta", "rc" + ])) + print(f"{version_str:<{padding}}", end=" ") + if (i + 1) % 8 == 0: + print() + + print()