├── .editorconfig ├── .flake8 ├── .gitattributes ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── README.plugin.md ├── docs └── poetry-dynamic-versioning.1 ├── poetry.lock ├── poetry_dynamic_versioning ├── __init__.py ├── __main__.py ├── backend.py ├── cli.py ├── patch.py └── plugin.py ├── pyproject.plugin.toml ├── pyproject.toml ├── tasks.py └── tests ├── __init__.py ├── dependency-classic ├── dependency_classic │ └── __init__.py └── setup.py ├── dependency-dynamic ├── dependency_dynamic │ └── __init__.py └── pyproject.toml ├── dependency-static ├── dependency_static │ └── __init__.py └── pyproject.toml ├── project-pep621 ├── project_pep621 │ └── __init__.py └── pyproject.toml ├── project ├── docs │ └── version.txt ├── project │ └── __init__.py └── pyproject.toml ├── test_integration.py └── test_unit.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{feature,json,md,yaml,yml}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv 3 | max-line-length = 100 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | name: Main 6 | 7 | jobs: 8 | pre-commit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-python@v5 13 | with: 14 | python-version: '3.10' 15 | - run: | 16 | pip install poetry==1.8.4 17 | poetry install 18 | poetry run pre-commit run --all-files --show-diff-on-failure 19 | 20 | test-poetry-v1: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | python-version: 26 | # * As of 2024-06-26, Poetry has started failing to install on 3.7: 27 | # `TypeError: Expected maxsize to be an integer or None` 28 | # * As of 2025-05-06, Poetry has started failing to install on 3.8: 29 | # `TypeError: 'ABCMeta' object is not subscriptable` 30 | - '3.9' 31 | - '3.10' 32 | - '3.11' 33 | poetry-version: 34 | - '1.2.2' 35 | - '1.3.2' 36 | - '1.4.2' 37 | - '1.5.1' 38 | - '1.6.1' 39 | - '1.7.1' 40 | - '1.8.4' 41 | unlocked: 42 | - false 43 | include: 44 | - python-version: '3.12' 45 | poetry-version: '1.8.4' 46 | unlocked: true 47 | - python-version: '3.13' 48 | poetry-version: '1.8.4' 49 | unlocked: true 50 | steps: 51 | - uses: actions/checkout@v4 52 | with: 53 | fetch-depth: 0 54 | - uses: actions/setup-python@v5 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | - if: ${{ matrix.unlocked }} 58 | run: | 59 | rm poetry.lock 60 | - run: | 61 | pip install pipx 62 | pipx install poetry==${{ matrix.poetry-version }} 63 | pipx install invoke 64 | 65 | # We could use `poetry install --extras plugin`, 66 | # but Poetry may decide to install a different version than `matrix.poetry-version`. 67 | poetry install 68 | poetry run pip install poetry==${{ matrix.poetry-version }} 69 | invoke test --pipx 70 | 71 | test-poetry-v2: 72 | runs-on: ubuntu-latest 73 | strategy: 74 | fail-fast: false 75 | matrix: 76 | python-version: 77 | - '3.9' 78 | - '3.10' 79 | - '3.11' 80 | poetry-version: 81 | - '2.0.1' 82 | - '2.1.1' 83 | unlocked: 84 | - false 85 | include: 86 | - python-version: '3.12' 87 | poetry-version: '2.0.0' 88 | unlocked: true 89 | - python-version: '3.13' 90 | poetry-version: '2.0.0' 91 | unlocked: true 92 | steps: 93 | - uses: actions/checkout@v4 94 | with: 95 | fetch-depth: 0 96 | - uses: actions/setup-python@v5 97 | with: 98 | python-version: ${{ matrix.python-version }} 99 | - run: | 100 | echo "USE_PEP621=1" >> $GITHUB_ENV 101 | - if: ${{ matrix.unlocked }} 102 | run: | 103 | rm poetry.lock 104 | - run: | 105 | pip install pipx 106 | pipx install poetry==${{ matrix.poetry-version }} 107 | pipx install invoke 108 | 109 | # We could use `poetry install --extras plugin`, 110 | # but Poetry may decide to install a different version than `matrix.poetry-version`. 111 | poetry install 112 | poetry run pip install poetry==${{ matrix.poetry-version }} 113 | invoke test --pipx 114 | 115 | test-poetry-dev: 116 | runs-on: ubuntu-latest 117 | continue-on-error: true 118 | strategy: 119 | fail-fast: false 120 | matrix: 121 | python-version: 122 | - '3.11' 123 | poetry-version: 124 | - 'git+https://github.com/python-poetry/poetry.git@main' 125 | steps: 126 | - uses: actions/checkout@v4 127 | with: 128 | fetch-depth: 0 129 | - uses: actions/setup-python@v5 130 | with: 131 | python-version: ${{ matrix.python-version }} 132 | - run: | 133 | echo "USE_PEP621=1" >> $GITHUB_ENV 134 | - run: | 135 | pip install pipx 136 | pipx install ${{ matrix.poetry-version }} 137 | pipx install invoke 138 | 139 | poetry install 140 | poetry run pip install ${{ matrix.poetry-version }} 141 | invoke test --pipx 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .cache/ 3 | .coverage 4 | .env/ 5 | .idea/ 6 | .mypy_cache/ 7 | .poetry/ 8 | .pytest_cache/ 9 | .ruff_cache/ 10 | .tox/ 11 | .vagrant/ 12 | .venv/ 13 | *.egg-info/ 14 | /setup.py 15 | build/ 16 | dist/ 17 | htmlcov/ 18 | pip-wheel-metadata/ 19 | *.log 20 | *.whl 21 | tests/project*/poetry.lock 22 | # created by github codespaces 23 | pythonenv*/ 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.1.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - repo: https://github.com/Lucas-C/pre-commit-hooks 8 | rev: v1.1.10 9 | hooks: 10 | - id: forbid-tabs 11 | - repo: https://github.com/mtkennerly/pre-commit-hooks 12 | rev: v0.3.0 13 | hooks: 14 | - id: poetry-black 15 | - id: poetry-mypy 16 | - id: poetry-ruff 17 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "codezombiech.gitignore", 4 | "EditorConfig.EditorConfig", 5 | "ms-python.mypy-type-checker", 6 | "ms-python.python", 7 | "redhat.vscode-yaml", 8 | "sidneys1.gitconfig", 9 | "streetsidesoftware.code-spell-checker", 10 | "tamasfe.even-better-toml", 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mypy-type-checker.importStrategy": "fromEnvironment", 3 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv", 4 | "yaml.format.enable": true, 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.8.2 (2025-03-28) 2 | 3 | * Fixed: 4 | * Possible `object has no attribute 'poetry'` error in some projects. 5 | 6 | ## v1.8.1 (2025-03-24) 7 | 8 | * Fixed: 9 | * `NameError` exception with Python 3.7. 10 | 11 | ## v1.8.0 (2025-03-20) 12 | 13 | * Added: 14 | * The `bump` config may now be set to a table, 15 | which supports an `index` field. 16 | * `commit-length` option to set a fixed length for commit hashes. 17 | * `{major}`, `{minor}`, and `{patch}` format placeholders. 18 | * Fixed: 19 | * Compatibility with other plugins (such as `poetry-monoranger-plugin`) 20 | that replace the `ConsoleCommandEvent.command.poetry` instance. 21 | ([Contributed by stevapple](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/222)) 22 | * Changed: 23 | * Updated Dunamai to 1.23.0+ for the latest features. 24 | 25 | ## v1.7.1 (2025-01-22) 26 | 27 | * Fixed: 28 | * There was an intermittent `ValueError` in PEP 621 mode, 29 | related to trying to remove `"version"` from `project.dynamic` 30 | when it had already been removed. 31 | 32 | ## v1.7.0 (2025-01-15) 33 | 34 | * Added: 35 | * The `enable` command now supports the bypass/override environment variables 36 | to use a different placeholder version than 0.0.0. 37 | * Fixed: 38 | * The `enable` command would add `"version"` to `project.dynamic` even if it were already present. 39 | ([Contributed by waketzheng](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/214)) 40 | * The `enable` command would set `tool.poetry.version` to `"0.0.0"` 41 | even if the field were already present with a different value. 42 | 43 | ## v1.6.0 (2025-01-10) 44 | 45 | * Added: 46 | * CLI: `show` command to print the version without changing any files. 47 | 48 | ## v1.5.2 (2025-01-09) 49 | 50 | * Fixed: 51 | * The formatting of `project.dynamic` and `tool.poetry.version` 52 | could be changed when triggering the plugin via `poetry dynamic-versioning` or `pip install` 53 | (but not via `poetry build` or `poetry-dynamic-versioning`). 54 | 55 | ## v1.5.1 (2025-01-09) 56 | 57 | * Fixed: 58 | * Array formatting was not preserved when changing `project.dynamic` in pyproject.toml. 59 | ([Contributed by edgarrmondragon](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/207)) 60 | 61 | ## v1.5.0 (2025-01-05) 62 | 63 | * Added: 64 | * CLI: When pyproject.toml contains the `project` section, 65 | the `enable` command will add the required fields for Poetry 2.0.0+. 66 | * Fixed: 67 | * For compatibility with Poetry 2.0.0, 68 | adjusted Poetry constraint from `^1.2.0` to `>=1.2.0`. 69 | * For compatibility with Poetry 2.0.0, 70 | when inserting the dynamic version into pyproject.toml with PEP 621 mode activated, 71 | the plugin ensures not to set `project.version` and `tool.poetry.version` at the same time. 72 | * An `UnboundLocalError` could happen when pyproject.toml was misconfigured. 73 | 74 | ## v1.4.1 (2024-09-10) 75 | 76 | * Fixed: 77 | * The `enable` command would fail when the pyproject.toml tables were out of order. 78 | 79 | ## v1.4.0 (2024-06-17) 80 | 81 | * Added: 82 | * The plugin now supports Poetry's upcoming PEP-621 functionality. 83 | More info here: https://github.com/python-poetry/poetry/issues/3332 84 | 85 | If your pyproject.toml defines `tool.poetry.name`, 86 | then the plugin will preserve its existing behavior. 87 | 88 | However, if your pyproject.toml: 89 | 90 | * does not define `tool.poetry.name` 91 | * defines `project.name` 92 | * defines `project.dynamic` to include `"version"` 93 | * does not define `project.version` 94 | 95 | ...then the plugin will enable its PEP-621 functionality. 96 | 97 | Because PEP-621 support is not yet released and finalized in Poetry itself, 98 | it is also subject to change in the plugin. 99 | 100 | ([Prototyped by edgarrmondragon](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/181)) 101 | 102 | ## v1.3.0 (2024-04-29) 103 | 104 | * Added: 105 | * `pattern-prefix` option to add a prefix to the version tag pattern. 106 | * `ignore-untracked` option to control the detection of dirty state. 107 | * `from-file` config section to read a version from a file instead of the VCS. 108 | * `POETRY_DYNAMIC_VERSIONING_DEBUG` environment variable for some logging. 109 | * Changed: 110 | * Updated Dunamai to 1.21.0+ for the latest features. 111 | 112 | ## v1.2.0 (2023-12-02) 113 | 114 | * Added: 115 | * `initial-content-jinja` option in `tool.poetry-dynamic-versioning.files` section. 116 | * Fixed: 117 | * Line ending style was not preserved in some cases because of the default behavior of `pathlib.Path.read_text`. 118 | To avoid this, `pathlib.Path.read_bytes` is used instead now. 119 | ([Contributed by nardi](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/157)) 120 | 121 | ## v1.1.1 (2023-10-27) 122 | 123 | * Fixed: 124 | * Custom substitutions in pyproject.toml weren't cleaned up correctly. 125 | This was because the plugin would record the "original" content of the file 126 | after the `version` and `enable` fields had already been changed. 127 | Now, substitutions are reverted first before reverting `version` and `enable`. 128 | 129 | ## v1.1.0 (2023-10-01) 130 | 131 | * Added: 132 | * `tool.poetry-dynamic-versioning.files` config section. 133 | This allows you to create a file in a default state before applying substitutions to it. 134 | You can also leave the substitutions in place when the plugin deactivates. 135 | 136 | ## v1.0.1 (2023-08-21) 137 | 138 | * Fixed: 139 | * Compatibility with poetry-core 1.7.0, which removed the `poetry.core.semver` module. 140 | * The `enable` command now constrains the plugin version to `>=1.0.0,<2.0.0` 141 | to protect against any potential API changes. 142 | 143 | ## v1.0.0 (2023-08-18) 144 | 145 | * Fixed: 146 | * Running `poetry dynamic-versioning` followed by `poetry build` 147 | would leave the plugin enabled in the sdist's pyproject.toml. 148 | 149 | ## v0.25.0 (2023-07-11) 150 | 151 | * Added: 152 | * `fix-shallow-repository` option to attempt to automatically fix shallow repositories. 153 | Currently, this only supports Git and will run `git fetch --unshallow`. 154 | * Changed: 155 | * Updated Dunamai to 1.18.0+ for the latest features. 156 | 157 | ## v0.24.0 (2023-06-30) 158 | 159 | * Added: 160 | * `POETRY_DYNAMIC_VERSIONING_COMMANDS_NO_IO` 161 | environment variable to prevent the plugin from modifying files during certain commands. 162 | The plugin still sets the dynamic version in memory so that Poetry itself can write it as needed. 163 | * Changed: 164 | * During `poetry version`, the plugin still activates, but no longer modifies pyproject.toml. 165 | 166 | ## v0.23.0 (2023-06-13) 167 | 168 | * Added: 169 | * CLI `enable` subcommand to enable the plugin in pyproject.toml. 170 | * Support for `POETRY_DYNAMIC_VERSIONING_OVERRIDE` environment variable. 171 | * `mode` option for substitution to support `__version_tuple__` style. 172 | * Changed: 173 | * CLI: `poetry dynamic-versioning` now outputs a summary of the changes, 174 | the same way that `poetry-dynamic-versioning` already did. 175 | 176 | ## v0.22.0 (2023-05-19) 177 | 178 | * Added: 179 | * The plugin will print a warning for shallow Git repositories 180 | (and any other `Concern`s reported by Dunamai in the future). 181 | This becomes an error with `strict = true`. 182 | * Changed: 183 | * Updated Dunamai to 1.17.0+ for the latest features and bug fixes. 184 | 185 | ## v0.21.5 (2023-05-15) 186 | 187 | * Fixed: 188 | * Compatibility with poetry-core 1.6.0+. 189 | * Changed: 190 | * `CHANGELOG.md` and `tests` are now included in sdists. 191 | 192 | ## v0.21.4 (2023-02-21) 193 | 194 | * Fixed: 195 | * In the Poetry CLI mode and standalone script mode, 196 | `path` dependencies received the same dynamic version as the active project. 197 | This issue did not affect the build backend mode. 198 | * Changed: 199 | * Updated Dunamai to 1.16.0+ for the latest features and bug fixes. 200 | 201 | ## v0.21.3 (2022-12-23) 202 | 203 | * Fixed: 204 | * Resolved a deprecation warning when used with Poetry Core 1.3.0+. 205 | ([Contributed by edgarrmondragon](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/106)) 206 | 207 | ## v0.21.2 (2022-12-16) 208 | 209 | * Fixed: 210 | * Line endings were not necessarily preserved because of the default behavior of `pathlib.Path.write_text`. 211 | To avoid this, `pathlib.Path.write_bytes` is used instead now. 212 | 213 | ## v0.21.1 (2022-11-08) 214 | 215 | * Fixed: 216 | * Warning for invalid config was printed to stdout instead of stderr. 217 | 218 | ## v0.21.0 (2022-11-07) 219 | 220 | * Added: 221 | * The plugin now prints a warning if its configuration is invalid. 222 | Right now, this just checks for unknown keys. 223 | * A `strict` option to prevent falling back to `0.0.0` when there are no tags. 224 | * Changed: 225 | * Updated Dunamai to 1.14.0+ for the latest features. 226 | This adds support for VCS archival files, namely ones produced by `git archive` and `hg archive`. 227 | Refer to [the Dunamai documentation](https://github.com/mtkennerly/dunamai#vcs-archives) for more info. 228 | 229 | ## v0.20.0 (2022-10-18) 230 | 231 | * Changed: 232 | * Updated Dunamai to 1.13.2+ for the latest features and bug fixes. 233 | In particular, this fixes an error when parsing Git output with `showSignature = true` configured. 234 | 235 | ## v0.19.0 (2022-09-16) 236 | 237 | * Fixed: 238 | * When using `poetry build`, the plugin did not properly disable itself in the 239 | copy of pyproject.toml included in source distributions, causing failures 240 | when trying to install them. 241 | * Added: 242 | * Support for activating the dynamic versioning only for certain Poetry commands 243 | (environment variable: `POETRY_DYNAMIC_VERSIONING_COMMANDS`). 244 | 245 | ## poetry-dynamic-versioning: v0.18.0 (2022-09-05) 246 | 247 | * Changed: 248 | * The minimum supported Python version is now 3.7. 249 | * The minimum supported Poetry version is now 1.2.0. 250 | * Import hacks have been eliminated in favor of a PEP 517 build backend wrapper 251 | around Poetry Core. 252 | * The two flavors of poetry-dynamic-versioning are now combined into one package 253 | via the optional `plugin` feature. 254 | 255 | ## poetry-dynamic-versioning-plugin: v0.4.0 (2022-09-05) 256 | 257 | * Deprecated the name `poetry-dynamic-versioning-plugin` 258 | in favor of a newly unified `poetry-dynamic-versioning`. 259 | 260 | ## poetry-dynamic-versioning-plugin: v0.3.2 (2022-05-25) 261 | 262 | * Fixed: 263 | * `poetry` did not work correctly when in a folder without a pyproject.toml. 264 | * `poetry plugin show` did not work correctly. 265 | 266 | ## poetry-dynamic-versioning: v0.17.1 & poetry-dynamic-versioning-plugin: v0.3.1 (2022-05-19) 267 | 268 | * Fixed: 269 | * CLI mode failed when pyproject.toml did not specify `enable = true`. 270 | 271 | ## poetry-dynamic-versioning: v0.17.0 & poetry-dynamic-versioning-plugin: v0.3.0 (2022-05-13) 272 | 273 | * Added: 274 | * Option `tool.poetry-dynamic-versioning.substitution.folders`. 275 | 276 | ## poetry-dynamic-versioning: v0.16.0 & poetry-dynamic-versioning-plugin: v0.2.0 (2022-05-07) 277 | 278 | * Changed: 279 | * Option `tool.poetry-dynamic-versioning.subversion.tag-dir` is now `tool.poetry-dynamic-versioning.tag-dir`. 280 | * Added: 281 | * Option `tool.poetry-dynamic-versioning.tag-branch`. 282 | * Option `tool.poetry-dynamic-versioning.full-commit`. 283 | 284 | ## poetry-dynamic-versioning-plugin: v0.1.0 (2022-04-28) 285 | 286 | * Changed: 287 | * The Poetry 1.2+ plugin now has a new name, `poetry-dynamic-versioning-plugin`, 288 | and this is its first release as a separate package. 289 | 290 | The import-hack-based pseudo-plugin will continue to be called `poetry-dynamic-versioning`. 291 | 292 | ## poetry-dynamic-versioning: v0.15.0 (2022-04-28) 293 | 294 | * Changed: 295 | * Internal improvements/refactoring to unify code base with `poetry-dynamic-versioning-plugin`, 296 | which is released as a separate package. These changes should not affect 297 | users of `poetry-dynamic-versioning`. 298 | 299 | ## v1.0.0b3 (2022-04-24) 300 | 301 | * Fixed: 302 | * The plugin can now update versions for dependencies that also use the plugin. 303 | 304 | ## v1.0.0b2 (2022-04-15) 305 | 306 | * Fixed: 307 | * The plugin maintained its own copy of the default `pattern`, which meant that 308 | it could fall behind the copy in Dunamai and lead to surprising behavior. 309 | The plugin now automatically uses the latest default from Dunamai directly 310 | when you do not customize it in the plugin settings. 311 | 312 | ## v0.14.1 (2022-04-14) 313 | 314 | * Fixed: 315 | * The plugin maintained its own copy of the default `pattern`, which meant that 316 | it could fall behind the copy in Dunamai and lead to surprising behavior. 317 | The plugin now automatically uses the latest default from Dunamai directly 318 | when you do not customize it in the plugin settings. 319 | 320 | ## v1.0.0b1 (2022-03-10) 321 | 322 | * Changed: 323 | * Implemented the official Poetry plugin interface. 324 | 325 | ## v0.14.0 (2022-03-09) 326 | 327 | * Changed: 328 | * The build backend is now poetry-core. 329 | ([Contributed by fabaff](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/63)) 330 | * The default list of `substitution.patterns` now handles `__version__` 331 | when it has a type annotation. 332 | ([Draft by da2ce7](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/64)) 333 | * Added: 334 | * Option to bypass the version control system and set a hard-coded version 335 | in an environment variable called `POETRY_DYNAMIC_VERSIONING_BYPASS`. 336 | ([Draft by jonringer](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/69)) 337 | * `branch`, `branch_escaped`, and `timestamp` formatting variables. 338 | 339 | ## v0.13.1 (2021-08-09) 340 | 341 | * Fixed an oversight where the default version tag pattern would only find 342 | tags with exactly three parts in the base (e.g., `v1.0.0` and `v1.2.3`). 343 | This is now relaxed so that `v1`, `v1.2.3.4`, and so on are also recognized. 344 | 345 | ## v0.13.0 (2021-05-26) 346 | 347 | * Changed: 348 | * Broadened version range of Jinja2 dependency to support projects that need 349 | a newer version. 350 | * Bumped the minimum Poetry version to 1.1.0, since the above Jinja2 change 351 | seemed to trip up Poetry 1.0.10 (on Python 3.7 and 3.8, but not 3.5 or 3.6, 352 | for some reason). 353 | * Fixed: 354 | * The plugin did not work on Fedora inside of Pip's isolated build 355 | environment, because the plugin would be loaded before some of its 356 | dependencies. Now, those imports are delayed until needed. 357 | 358 | ## v0.12.7 (2021-05-20) 359 | 360 | * Fixed: 361 | * Parsing files containing special UTF-8 characters would result in an error. 362 | Files are now assumed to be UTF-8. 363 | ([Contributed by rhorenov](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/50)) 364 | 365 | ## v0.12.6 (2021-04-19) 366 | 367 | * Fixed: 368 | * The previous `bump` fix only applied to `format-jinja`. It has now been 369 | fixed for other scenarios as well. 370 | 371 | ## v0.12.5 (2021-04-18) 372 | 373 | * Fixed: 374 | * When the `bump` option was enabled, the version would be bumped even when on 375 | a commit with a version tag. Now, no bumping occurs when on such a commit. 376 | 377 | ## v0.12.4 (2021-03-05) 378 | 379 | * Fixed: 380 | * An incompatibility with `tox-poetry-installer` where the working directory 381 | was received as a `str` instead of a `Path`. 382 | ([Contributed by cburgess](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/41)) 383 | 384 | ## v0.12.3 (2021-02-19) 385 | 386 | * Fixed: 387 | * Previously, when building a source distribution with `poetry build`, the 388 | plugin's config section would retain the `enable = true` setting, which 389 | would then cause an error when installing the artifact since the VCS info 390 | would not be available. 391 | (This was not an issue for wheels generated by `poetry build`.) 392 | 393 | The dynamic version from build time is still present in the source 394 | distribution, so there is no need for the plugin at install time. 395 | Therefore, the plugin now temporarily sets `enable = false` so that that 396 | value will be placed in the source distribution, then restores the original 397 | setting for development purposes. 398 | 399 | ## v0.12.2 (2021-01-30) 400 | 401 | * Fixed: 402 | * Another possible exception when applying patches if only `poetry-core` was 403 | installed and not the Poetry tool, particularly combined with Tox. 404 | 405 | ## v0.12.1 (2021-01-04) 406 | 407 | * Fixed: 408 | * Possible exception when applying patches if only `poetry-core` was 409 | installed and not the Poetry tool. 410 | 411 | ## v0.12.0 (2020-12-05) 412 | 413 | * Added: 414 | * `tagged-metadata` option, for the corresponding Dunamai feature. 415 | ([Contributed by mariusvniekerk](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/32)) 416 | 417 | ## v0.11.0 (2020-11-21) 418 | 419 | * Added: 420 | * `bump` option. 421 | * Fixed: 422 | * `poetry shell` did not clean up after itself. 423 | 424 | ## v0.10.0 (2020-10-08) 425 | 426 | * Added: 427 | * Support for patching `poetry-core` when used as a standalone build system. 428 | 429 | ## v0.9.0 (2020-09-27) 430 | 431 | * Changed: 432 | * Dropped support for `pip wheel .` and bumped the minimum Poetry version to 433 | 1.0.2 in order to enable fixing the following issue. 434 | * Fixed: 435 | * The main project's dynamic version would be applied to path/Git dependencies. 436 | Now, the plugin tracks state and configuration for each dependency separately 437 | in order to correctly report their versions. 438 | * `poetry run` did not always clean up after itself. 439 | * `poetry.semver.version` could not be imported because it was moved to 440 | `poetry.core.semver.version` starting in Poetry 1.1.0a1. The plugin can now 441 | handle either location. 442 | 443 | ## v0.8.3 (2020-08-07) 444 | 445 | * Fixed a possible issue with string interning in glob handling. 446 | ([Contributed by mariusvniekerk](https://github.com/mtkennerly/poetry-dynamic-versioning/pull/18)) 447 | 448 | ## v0.8.2 (2020-07-06) 449 | 450 | * Fixed an issue with Python 3.5 compatibility. 451 | (Contributed by [gsemet](https://github.com/gsemet)) 452 | * Increased minimum Dunamai version to propagate the latest updates. 453 | 454 | ## v0.8.1 (2020-05-29) 455 | 456 | * Fixed an issue where CLI mode did not persist the change to pyproject.toml. 457 | This problem was missed because of an issue in the integration tests, 458 | which are fixed now as well. 459 | 460 | ## v0.8.0 (2020-05-28) 461 | 462 | * Added the option `format-jinja-imports`. 463 | * Added support for Pip's PEP 517 isolated builds. 464 | * In CLI mode: 465 | * Improved handling of error conditions. 466 | * Added output of the generated version and any modified files. 467 | * Removed handling for Poetry versions prior to 1.0.0. 468 | * Avoid writing files if the content does not need to be changed. 469 | 470 | ## v0.7.0 (2020-05-14) 471 | 472 | * Added a CLI mode. 473 | 474 | ## v0.6.0 (2020-03-22) 475 | 476 | * Expose new Dunamai functions via `format-jinja`: 477 | * `bump_version` 478 | * `serialize_pep440` 479 | * `serialize_pvp` 480 | * `serialize_semver` 481 | 482 | ## v0.5.0 (2020-02-12) 483 | 484 | * Added the `format-jinja` option. 485 | 486 | ## v0.4.0 (2019-12-13) 487 | 488 | * Added the ability to update the version in other files than pyproject.toml. 489 | 490 | ## v0.3.2 (2019-12-13) 491 | 492 | * Fixed an issue with Poetry 1.0.0b2 and newer where the original version 493 | would not be restored after `poetry run`. 494 | (Contributed by [lmoretto](https://github.com/lmoretto)) 495 | 496 | ## v0.3.1 (2019-11-28) 497 | 498 | * Fixed [#3](https://github.com/mtkennerly/poetry-dynamic-versioning/issues/3) 499 | where the plugin would revert not only the dynamic version change in pyproject.toml, 500 | but also any other changes, such as the addition of new dependencies. 501 | (Contributed by [lmoretto](https://github.com/lmoretto)) 502 | 503 | ## v0.3.0 (2019-10-27) 504 | 505 | * Updated for Dunamai v1.0.0: 506 | * Added support for Fossil. 507 | * Better error reporting, such as when no tags match the expected pattern. 508 | * Custom formatting: 509 | * Renamed `{post}` to `{distance}` 510 | * Renamed `{pre_type}` to `{stage}` 511 | * Renamed `{pre_number}` to `{revision}` 512 | * Removed `{epoch}` 513 | * Removed `{dev}` 514 | 515 | ## v0.2.0 (2019-10-19) 516 | 517 | * Added support for Poetry 1.0.0b2. 518 | 519 | ## v0.1.0 (2019-06-05) 520 | 521 | * Initial release. 522 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | This project is managed using [Poetry](https://poetry.eustace.io). 3 | 4 | * If you want to take advantage of the default VSCode integration, 5 | then first configure Poetry to make its virtual environment in the repository: 6 | ``` 7 | poetry config virtualenvs.in-project true 8 | ``` 9 | * After cloning the repository, activate the tooling: 10 | ``` 11 | pipx install invoke 12 | poetry install --extras plugin 13 | poetry run pre-commit install 14 | ``` 15 | 16 | Commands defined in `tasks.py`: 17 | 18 | * Load the deprecated `poetry-dynamic-versioning-plugin` package and switch back: 19 | ``` 20 | invoke pdvp 21 | invoke pdv 22 | ``` 23 | * Build the currently loaded package: 24 | ``` 25 | invoke build 26 | ``` 27 | * Run tests for the currently loaded package: 28 | ``` 29 | invoke test 30 | ``` 31 | [Git Bash](https://git-scm.com) is recommended for Windows. 32 | 33 | ## Release 34 | * Run `invoke prerelease` 35 | * Verify the changes and `git add` them 36 | * Run `invoke release` 37 | * Create a release on GitHub for the new tag and attach the artifacts from `dist` 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthew T. Kennerly (mtkennerly) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic versioning plugin for Poetry 2 | This is a Python 3.7+ plugin for [Poetry 1.2.0+](https://github.com/sdispater/poetry) 3 | and [Poetry Core 1.0.0+](https://github.com/python-poetry/poetry-core) 4 | to enable dynamic versioning based on tags in your version control system, 5 | powered by [Dunamai](https://github.com/mtkennerly/dunamai). 6 | Many different version control systems are supported, including Git and Mercurial; 7 | please refer to the Dunamai page for the full list 8 | (and minimum supported version where applicable). 9 | 10 | Once the plugin is set up, 11 | you can create tags in your version control system (e.g., `git tag v0.1.0 -m "New release"`), 12 | and the plugin will automatically insert that version into relevant files 13 | (like `pyproject.toml` or `*.py` files with a `__version__` line) 14 | for the duration of Poetry commands such as `poetry build`, 15 | then revert the changes afterwards to keep your repository clean. 16 | The plugin aims to inject a static copy of the version into build artifacts (sdists/wheels) 17 | without the need to manually edit and commit changed files in your repository. 18 | 19 | `poetry-dynamic-versioning` provides a build backend that patches Poetry Core 20 | to enable the versioning system in PEP 517 build frontends. 21 | When installed with the `plugin` feature (i.e., `poetry-dynamic-versioning[plugin]`), 22 | it also integrates with the Poetry CLI to trigger the versioning in commands like `poetry build`. 23 | 24 | For Poetry 1.1.x, you can use an older version of `poetry-dynamic-versioning` (0.17.1 or earlier) 25 | that relied on a `*.pth` import hack, but this is no longer supported, 26 | so you should migrate to the standardized plugin and Poetry 1.2.0+. 27 | 28 | ## Installation 29 | If you've previously installed the deprecated `poetry-dynamic-versioning-plugin` package, 30 | be sure to uninstall it before proceeding. 31 | 32 | * Install the plugin using one of the options below: 33 | 34 | * For Poetry 2.0.0+, add this to your pyproject.toml and then run `poetry install`: 35 | ```toml 36 | [tool.poetry.requires-plugins] 37 | poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] } 38 | ``` 39 | * For older Poetry versions, depending on how you installed Poetry: 40 | * In most cases: `poetry self add "poetry-dynamic-versioning[plugin]"` 41 | * If you installed Poetry with Pipx: `pipx inject poetry "poetry-dynamic-versioning[plugin]"` 42 | 43 | See the [Poetry plugin documentation](https://python-poetry.org/docs/plugins/#using-plugins) 44 | for more information about these options. 45 | * Run this in your project: `poetry dynamic-versioning enable` 46 | 47 | Or you can update your pyproject.toml manually to include these fields: 48 | 49 | ```toml 50 | [project] 51 | dynamic = ["version"] 52 | 53 | [tool.poetry] 54 | version = "0.0.0" 55 | 56 | [tool.poetry-dynamic-versioning] 57 | enable = true 58 | ``` 59 | 60 | Include the plugin in the `build-system` section of pyproject.toml 61 | for interoperability with PEP 517 build frontends: 62 | 63 | ```toml 64 | [build-system] 65 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 66 | build-backend = "poetry_dynamic_versioning.backend" 67 | ``` 68 | 69 | This is a thin wrapper around `poetry.core.masonry.api`. 70 | 71 | Poetry still requires the `tool.poetry.version` field to be present in pyproject.toml, 72 | but you are encouraged to use `version = "0.0.0"` as a standard placeholder. 73 | 74 | With the minimal configuration above, 75 | the plugin will automatically take effect when you run commands such as `poetry build`. 76 | It will update the version in pyproject.toml, then revert the change when the plugin deactivates. 77 | 78 | The default configuration will also update any pre-existing 79 | `__version__ = "0.0.0"` and `__version_tuple__ = (0, 0, 0)` placeholders in some files. 80 | You can configure additional substitution patterns/files as needed (see below). 81 | 82 | ## Configuration 83 | In your pyproject.toml file, you may configure the following options: 84 | 85 | * `[tool.poetry-dynamic-versioning]`: 86 | General options. 87 | * `enable` (boolean, default: false): 88 | Since the plugin has to be installed globally, 89 | this setting is an opt-in per project. 90 | * `vcs` (string, default: `any`): 91 | This is the version control system to check for a version. 92 | One of: `any`, `git`, `mercurial`, `darcs`, `bazaar`, `subversion`, `fossil`, `pijul`. 93 | * `metadata` (boolean, default: unset): 94 | If true, include the commit hash in the version, 95 | and also include a dirty flag if `dirty` is true. 96 | If unset, metadata will only be included if you are on a commit without a version tag. 97 | This is ignored when `format` or `format-jinja` is used. 98 | * `tagged-metadata` (boolean, default: false): 99 | If true, include any tagged metadata discovered as the first part of the metadata segment. 100 | Has no effect when `metadata` is set to false. 101 | This is ignored when `format` or `format-jinja` is used. 102 | * `dirty` (boolean, default: false): 103 | If true, include a dirty flag in the metadata, 104 | indicating whether there are any uncommitted changes. 105 | Has no effect when `metadata` is set to false. 106 | This is ignored when `format` or `format-jinja` is used. 107 | * `pattern` (string): 108 | This is a regular expression which will be used to find a tag representing a version. 109 | When this is unset, Dunamai's default pattern is used. 110 | 111 | There must be a capture group named `base` with the main part of the version. 112 | Optionally, it may contain another two groups named `stage` and `revision` for prereleases, 113 | and it may contain a group named `tagged_metadata` to be used with the `tagged-metadata` option. 114 | There may also be a group named `epoch` for the PEP 440 concept. 115 | 116 | If the `base` group is not included, 117 | then this will be interpreted as a named preset from the Dunamai `Pattern` class. 118 | This includes: `default`, `default-unprefixed` (makes the `v` prefix optional). 119 | 120 | You can check the default for your installed version of Dunamai by running this command: 121 | ``` 122 | poetry run python -c "import dunamai; print(dunamai.Pattern.Default.regex())" 123 | ``` 124 | 125 | Remember that backslashes must be escaped in the TOML file. 126 | 127 | ```toml 128 | # Regular expression: 129 | pattern = '(?P\d+\.\d+\.\d+)' 130 | # Named preset: 131 | pattern = "default-unprefixed" 132 | ``` 133 | * `pattern-prefix` (string): 134 | This will be inserted after the pattern's start anchor (`^`). 135 | For example, to match tags like `some-package-v1.2.3`, 136 | you can keep the default pattern and set the prefix to `some-package-`. 137 | * `format` (string, default: unset): 138 | This defines a custom output format for the version. Available substitutions: 139 | 140 | * `{base}` 141 | * `{stage}` 142 | * `{revision}` 143 | * `{distance}` 144 | * `{commit}` 145 | * `{dirty}` 146 | * `{tagged_metadata}` 147 | * `{branch}` 148 | * `{branch_escaped}` which omits any non-letter/number characters 149 | * `{timestamp}` of the current commit, which expands to YYYYmmddHHMMSS as UTC 150 | * `{major}` (first part of `base` split on `.`, or 0) 151 | * `{minor}` (second part of `base` split on `.`, or 0) 152 | * `{patch}` (third part of `base` split on `.`, or 0) 153 | 154 | Example: `v{base}+{distance}.{commit}` 155 | * `format-jinja` (string, default: unset): 156 | This defines a custom output format for the version, 157 | using a [Jinja](https://pypi.org/project/Jinja2) template. 158 | When this is set, `format` is ignored. 159 | 160 | Available variables: 161 | 162 | * `base` (string) 163 | * `stage` (string or None) 164 | * `revision` (integer or None) 165 | * `distance` (integer) 166 | * `commit` (string) 167 | * `dirty` (boolean) 168 | * `tagged_metadata` (string or None) 169 | * `version` (dunumai.Version) 170 | * `env` (dictionary of environment variables) 171 | * `branch` (string or None) 172 | * `branch_escaped` (string or None) 173 | * `timestamp` (string or None) 174 | * `major` (integer) 175 | * `minor` (integer) 176 | * `patch` (integer) 177 | 178 | Available functions: 179 | 180 | * `bump_version` ([from Dunamai](https://github.com/mtkennerly/dunamai/blob/fe69436133d0ea2097a9fed1dbb31ec96263cf83/dunamai/__init__.py#L1965-L1977)) 181 | * `serialize_pep440` ([from Dunamai](https://github.com/mtkennerly/dunamai/blob/fe69436133d0ea2097a9fed1dbb31ec96263cf83/dunamai/__init__.py#L1863-L1886)) 182 | * `serialize_semver` ([from Dunamai](https://github.com/mtkennerly/dunamai/blob/fe69436133d0ea2097a9fed1dbb31ec96263cf83/dunamai/__init__.py#L1917-L1931)) 183 | * `serialize_pvp` ([from Dunamai](https://github.com/mtkennerly/dunamai/blob/fe69436133d0ea2097a9fed1dbb31ec96263cf83/dunamai/__init__.py#L1945-L1954)) 184 | 185 | Simple example: 186 | 187 | ```toml 188 | format-jinja = "{% if distance == 0 %}{{ base }}{% else %}{{ base }}+{{ distance }}.{{ commit }}{% endif %}" 189 | ``` 190 | 191 | Complex example: 192 | 193 | ```toml 194 | format-jinja = """ 195 | {%- if distance == 0 -%} 196 | {{ serialize_pep440(base, stage, revision) }} 197 | {%- elif revision is not none -%} 198 | {{ serialize_pep440(base, stage, revision + 1, dev=distance, metadata=[commit]) }} 199 | {%- else -%} 200 | {{ serialize_pep440(bump_version(base), stage, revision, dev=distance, metadata=[commit]) }} 201 | {%- endif -%} 202 | """ 203 | ``` 204 | * `format-jinja-imports` (array of tables, default: empty): 205 | This defines additional things to import and make available to the `format-jinja` template. 206 | Each table must contain a `module` key and may also contain an `item` key. Consider this example: 207 | 208 | ```toml 209 | format-jinja-imports = [ 210 | { module = "foo" }, 211 | { module = "bar", item = "baz" }, 212 | ] 213 | ``` 214 | 215 | This is roughly equivalent to: 216 | 217 | ```python 218 | import foo 219 | from bar import baz 220 | ``` 221 | 222 | `foo` and `baz` would then become available in the Jinja formatting. 223 | * `style` (string, default: unset): 224 | One of: `pep440`, `semver`, `pvp`. 225 | These are preconfigured output formats. 226 | If you set both a `style` and a `format`, 227 | then the format will be validated against the style's rules. 228 | If `style` is unset, the default output format will follow PEP 440, 229 | but a custom `format` will only be validated if `style` is set explicitly. 230 | 231 | Regardless of the style you choose, 232 | the dynamic version is ultimately subject to Poetry's validation as well, 233 | and Poetry is designed around PEP 440 versions. 234 | Poetry can usually understand SemVer/etc input, 235 | but sometimes, Poetry may reject an otherwise valid version format. 236 | * `latest-tag` (boolean, default: false): 237 | If true, then only check the latest tag for a version, 238 | rather than looking through all the tags until a suitable one is found to match the `pattern`. 239 | * `bump` (boolean or table, default: false): 240 | If enabled, then increment the last part of the version `base` by 1, 241 | unless the `stage` is set, 242 | in which case increment the `revision` by 1 or set it to a default of 2 if there was no `revision`. 243 | Does nothing when on a commit with a version tag. 244 | 245 | One of: 246 | 247 | * When set to a boolean, true means enable bumping, with other settings as default. 248 | * When set to a table, these fields are allowed: 249 | * `enable` (boolean, default: false): 250 | If true, enable bumping. 251 | * `index` (integer, default: -1): 252 | Numerical position to increment in the base. 253 | This follows Python indexing rules, so positive numbers start from 254 | the left side and count up from 0, while negative numbers start from 255 | the right side and count down from -1. 256 | 257 | Example, if there have been 3 commits since the `v1.3.1` tag: 258 | * PEP 440 with `bump = false`: `1.3.1.post3.dev0+28c1684` 259 | * PEP 440 with `bump = true`: `1.3.2.dev3+28c1684` 260 | * `tag-dir` (string, default: `tags`): 261 | This is the location of tags relative to the root. 262 | This is only used for Subversion. 263 | * `tag-branch` (string, default: unset): 264 | Branch on which to find tags, if different than the current branch. 265 | This is only used for Git currently. 266 | * `full-commit` (boolean, default: false): 267 | If true, get the full commit hash instead of the short form. 268 | This is only used for Git and Mercurial. 269 | * `strict` (boolean, default: false): 270 | If true, then fail instead of falling back to 0.0.0 when there are no tags. 271 | * `fix-shallow-repository` (boolean, default: false): 272 | If true, then automatically try to fix shallow repositories. 273 | Currently, this only supports Git and will run `git fetch --unshallow`. 274 | * `ignore-untracked` (boolean, default: false): 275 | If true, ignore untracked files when determining whether the repository is dirty. 276 | * `commit-length` (integer, default: unset): 277 | Use this many characters from the start of the full commit hash. 278 | * `[tool.poetry-dynamic-versioning.substitution]`: 279 | Insert the dynamic version into additional files other than just pyproject.toml. 280 | These changes will be reverted when the plugin deactivates. 281 | * `files` (array of strings): Globs for any files that need substitutions. Default: 282 | `["*.py", "*/__init__.py", "*/__version__.py", "*/_version.py"]`. 283 | To disable substitution, set this to an empty list. 284 | * `patterns` (array of strings/tables): 285 | Regular expressions for the text to replace. 286 | Each regular expression must have two capture groups, 287 | which are any text to preserve before and after the replaced text. 288 | 289 | String items are interpreted as a regular expression directly. 290 | Table items support these keys: 291 | * `value` (string): 292 | This is the regular expression. 293 | * `mode` (string, optional): 294 | This controls how the version should be inserted. Options: 295 | * `str` (default): 296 | Serialize version as-is. 297 | The capture groups should already include the surrounding quotation marks. 298 | * `tuple`: 299 | Serialize `0.1.2.dev0+a.b` as `0, 1, 2, "dev0", "a.b"`. 300 | The capture groups should already include the surrounding parentheses. 301 | 302 | Default: 303 | 304 | ```toml 305 | patterns = [ 306 | '''(^__version__\s*(?::.*?)?=\s*['"])[^'"]*(['"])''', 307 | { value = '''(^__version_tuple__\s*(?::.*?)?=\s*\()[^)]*(\))''', mode = "tuple" }, 308 | ] 309 | ``` 310 | 311 | Remember that the backslashes must be escaped in the TOML file. 312 | * `folders` (array of tables, default: empty): 313 | List of additional folders to check for substitutions. 314 | 315 | Each table supports these options: 316 | 317 | * `path` (string, required): 318 | Path to the folder. 319 | * `files` (array of strings, optional): 320 | Override `substitution.files` for this folder. 321 | * `patterns` (array of strings, optional): 322 | Override `substitution.patterns` for this folder. 323 | 324 | If you use an `src` layout, 325 | you may want to keep the default `files`/`patterns` and just specify the following: 326 | 327 | ```toml 328 | folders = [ 329 | { path = "src" } 330 | ] 331 | ``` 332 | 333 | This will check the default file globs (e.g., `./*.py`) 334 | as well as the same file globs inside of `src` (e.g., `./src/*.py`). 335 | * `[tool.poetry-dynamic-versioning.files]` (table, default: empty): 336 | This section lets you tweak the behavior for individual files. 337 | Each table key is a path to a specific file (no globs) relative to the project root. 338 | Each nested table supports these fields: 339 | 340 | If you use these options to generate a file that's ignored by your VCS, 341 | but you also want the generated file to be included in the output of `poetry build`, 342 | then you'll need to name the file explicitly in your `tool.poetry.include` config. 343 | 344 | * `persistent-substitution` (boolean, optional): 345 | If true, then do not revert any substitutions applied to this file. 346 | This is primarily useful for editable installs, if you need the version to remain in a file ignored by your VCS. 347 | * `initial-content` (string, optional): 348 | Set the file content before the substitution phase. 349 | The file will be created or overwritten as necessary. 350 | Common leading whitespace will be stripped from each line. 351 | * `initial-content-jinja` (string, optional): 352 | Same as `initial-content`, but using Jinja formatting. 353 | If both options are set, this one takes priority. 354 | You can use the same imports from `format-jinja-imports` and the same variables from `format-jinja`, 355 | with this additional variable: 356 | 357 | * `formatted_version` (string) - version formatted by either the `format` or `format-jinja` option 358 | 359 | Example: 360 | 361 | ```toml 362 | [tool.poetry-dynamic-versioning.files."package/_version.py"] 363 | persistent-substitution = true 364 | initial-content = """ 365 | # These version placeholders will be replaced later during substitution. 366 | __version__ = "0.0.0" 367 | __version_tuple__ = (0, 0, 0) 368 | """ 369 | ``` 370 | * `[tool.poetry-dynamic-versioning.from-file]`: 371 | This section lets you read the version from a file instead of the VCS. 372 | 373 | * `source` (string): 374 | If set, read the version from this file. 375 | It must be a path relative to the location of pyproject.toml. 376 | By default, the plugin will read the entire content of the file, 377 | without leading and trailing whitespace. 378 | * `pattern` (string): 379 | If set, use this regular expression to extract the version from the file. 380 | The first capture group must contain the version. 381 | 382 | Simple example: 383 | 384 | ```toml 385 | [tool.poetry-dynamic-versioning] 386 | enable = true 387 | vcs = "git" 388 | style = "semver" 389 | ``` 390 | 391 | ## Environment variables 392 | In addition to the project-specific configuration above, 393 | you can apply some global overrides via environment variables. 394 | 395 | * `POETRY_DYNAMIC_VERSIONING_BYPASS`: 396 | Use this to bypass the VCS mechanisms and use a static version instead. 397 | The value of the environment variable will be used as the version 398 | for the active project and any path/SSH dependencies that also use the plugin. 399 | This is mainly for distro package maintainers who need to patch existing releases, 400 | without needing access to the original repository. 401 | * `POETRY_DYNAMIC_VERSIONING_OVERRIDE`: 402 | Use a static version for specific packages only, but leave dynamic versioning enabled otherwise. 403 | For example, `pkg1 = 0.1.0, pkg2 = 0.2.0` (spaces are optional) would set pkg1 to 0.1.0 and pkg2 to 0.2.0. 404 | This only affects packages for which poetry-dynamic-versioning is enabled. 405 | When both variables are set, `OVERRIDE` takes precedence over `BYPASS`. 406 | * `POETRY_DYNAMIC_VERSIONING_COMMANDS`: 407 | You can set a comma-separated list of Poetry commands during which to activate the versioning. 408 | For example, `build,publish` will limit the dynamic versioning to those two commands. 409 | Similarly, setting `POETRY_DYNAMIC_VERSIONING_COMMANDS=""` will disable the dynamic versioning altogether, 410 | which is useful in isolated environments like Docker where versions may not be computable and not needed anymore. 411 | * `POETRY_DYNAMIC_VERSIONING_COMMANDS_NO_IO`: 412 | Comma-separated list of Poetry commands during which the plugin should **not** directly modify files. 413 | The plugin will still set the dynamic version in memory so that Poetry itself can write it as needed. 414 | Default: `version`. 415 | * `POETRY_DYNAMIC_VERSIONING_DEBUG`: 416 | If this is set to `1`, then some debug logs will be printed to stderr. 417 | Right now, this logs some cases where substitution doesn't find anything to change. 418 | 419 | ## Command line mode 420 | The plugin also has a command line mode for execution on demand. 421 | This mode applies the dynamic version to all relevant files and leaves the changes in-place, 422 | allowing you to inspect the result. 423 | Your configuration will be detected from pyproject.toml as normal, 424 | but the `enable` option is not necessary. 425 | 426 | To activate this mode, 427 | either use `poetry dynamic-versioning` (provided by the `plugin` feature) 428 | or `poetry-dynamic-versioning` (standalone script with default features). 429 | 430 | ## VCS archives 431 | Sometimes, you may only have access to an archive of a repository (e.g., a zip file) without the full history. 432 | The plugin can still detect a version in some of these cases. 433 | Refer to [the Dunamai documentation](https://github.com/mtkennerly/dunamai#vcs-archives) for more info. 434 | 435 | ## Caveats 436 | All of [Dunamai's caveats](https://github.com/mtkennerly/dunamai#other-notes) apply. 437 | In addition to those: 438 | 439 | * The dynamic version is not available during `poetry run` or `poetry shell` 440 | because of a [Poetry design choice](https://github.com/python-poetry/poetry/issues/8092) 441 | that prevents the plugin from cleaning up after itself. 442 | * Regarding PEP 517 support: 443 | 444 | `pip wheel .` and `pip install .` will work with new enough Pip versions 445 | that default to in-tree builds or support the `--use-feature=in-tree-build` option. 446 | Older versions of Pip will not work because they create an isolated copy of the source code 447 | that does not contain the version control history. 448 | 449 | If you want to build wheels of your dependencies, you can do the following, 450 | although local path-based dependencies may not work: 451 | 452 | ``` 453 | poetry export -f requirements.txt -o requirements.txt --without-hashes 454 | pip wheel -r requirements.txt 455 | ``` 456 | * Regarding PEP 621 support: 457 | 458 | The plugin supports the PEP 621 mode of Poetry 2.0.0+ (`[project]`) 459 | as well as the classic mode of older Poetry versions (`[tool.poetry]`). 460 | Which mode the plugin uses depends on how your pyproject.toml is configured: 461 | 462 | * For the plugin to use the classic mode, your pyproject.toml must: 463 | * specify `tool.poetry.name` 464 | * For the plugin to use the PEP 621 mode, your pyproject.toml must: 465 | * not specify `tool.poetry.name` 466 | * specify `tool.poetry.version` (a placeholder like `"0.0.0"` is fine) 467 | * specify `project.name` 468 | * specify `project.dynamic` to contain `"version"` 469 | * not specify `project.version` 470 | 471 | Note that, in PEP 621 mode, 472 | the plugin must temporarily remove and then re-add the `tool.poetry.version` key. 473 | This can result in the key shifting to the bottom of its table. 474 | Please put the key at the bottom preemptively to avoid any conflicts. 475 | -------------------------------------------------------------------------------- /README.plugin.md: -------------------------------------------------------------------------------- 1 | # Dynamic versioning plugin for Poetry 2 | This is a Python 3.7+ plugin for [Poetry 1.2.0+](https://github.com/sdispater/poetry) 3 | to enable dynamic versioning based on tags in your version control system, 4 | powered by [Dunamai](https://github.com/mtkennerly/dunamai). 5 | 6 | The package `poetry-dynamic-versioning-plugin` has been merged into 7 | [`poetry-dynamic-versioning`](https://pypi.org/project/poetry-dynamic-versioning), 8 | under the optional feature named `plugin`. Please install that package instead. 9 | -------------------------------------------------------------------------------- /docs/poetry-dynamic-versioning.1: -------------------------------------------------------------------------------- 1 | .TH POETRY\-DYNAMIC\-VERSIONING "1" "2025\-03\-28" "poetry\-dynamic\-versioning \- Dynamic versioning plugin for Poetry 1.8.2" "poetry\-dynamic\-versioning" 2 | .SH NAME 3 | poetry\-dynamic\-versioning 4 | .SH SYNOPSIS 5 | .B poetry\-dynamic\-versioning 6 | [-h] {enable,show} ... 7 | .SH DESCRIPTION 8 | Apply the dynamic version to all relevant files and leave the changes in\-place. This allows you to activate the plugin behavior on demand and inspect the result. Your configuration will be detected from pyproject.toml as normal. 9 | 10 | .SH 11 | SUBCOMMANDS 12 | .SS \fBpoetry\-dynamic\-versioning enable\fR 13 | Update pyproject.toml to enable the plugin using a typical configuration. The output may not be suitable for more complex use cases. 14 | 15 | usage: poetry\-dynamic\-versioning enable [\-h] 16 | .SS \fBpoetry\-dynamic\-versioning show\fR 17 | Print the version without changing any files. 18 | 19 | usage: poetry\-dynamic\-versioning show [\-h] 20 | 21 | .SH AUTHOR 22 | .nf 23 | Matthew T. Kennerly (mtkennerly) 24 | .fi 25 | 26 | .SH DISTRIBUTION 27 | The latest version of poetry\-dynamic\-versioning \- Dynamic versioning plugin for Poetry may be downloaded from 28 | .UR https://github.com/mtkennerly/poetry\-dynamic\-versioning 29 | .UE 30 | -------------------------------------------------------------------------------- /poetry_dynamic_versioning/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [] # type: ignore 2 | 3 | import copy 4 | import datetime as dt 5 | import os 6 | import re 7 | import shlex 8 | import subprocess 9 | import sys 10 | import textwrap 11 | from enum import Enum 12 | from importlib import import_module 13 | from pathlib import Path 14 | from typing import Mapping, MutableMapping, Optional, Sequence, Tuple, Union 15 | 16 | import jinja2 17 | import tomlkit 18 | import tomlkit.items 19 | from dunamai import ( 20 | bump_version, 21 | check_version, 22 | Concern, 23 | Pattern, 24 | serialize_pep440, 25 | serialize_pvp, 26 | serialize_semver, 27 | Style, 28 | Vcs, 29 | Version, 30 | ) 31 | 32 | _BYPASS_ENV = "POETRY_DYNAMIC_VERSIONING_BYPASS" 33 | _OVERRIDE_ENV = "POETRY_DYNAMIC_VERSIONING_OVERRIDE" 34 | _DEBUG_ENV = "POETRY_DYNAMIC_VERSIONING_DEBUG" 35 | 36 | if sys.version_info >= (3, 8): 37 | from typing import TypedDict 38 | 39 | _SubstitutionPattern = TypedDict( 40 | "_SubstitutionPattern", 41 | { 42 | "value": str, 43 | "mode": Optional[str], 44 | }, 45 | ) 46 | 47 | _SubstitutionFolder = TypedDict( 48 | "_SubstitutionFolder", 49 | { 50 | "path": str, 51 | "files": Optional[Sequence[str]], 52 | "patterns": Optional[Sequence[Union[str, _SubstitutionPattern]]], 53 | }, 54 | ) 55 | 56 | _Substitution = TypedDict( 57 | "_Substitution", 58 | { 59 | "files": Sequence[str], 60 | "patterns": Sequence[Union[str, _SubstitutionPattern]], 61 | "folders": Sequence[_SubstitutionFolder], 62 | }, 63 | ) 64 | 65 | _File = TypedDict( 66 | "_File", 67 | { 68 | "persistent-substitution": Optional[bool], 69 | "initial-content": Optional[str], 70 | "initial-content-jinja": Optional[str], 71 | }, 72 | ) 73 | 74 | _JinjaImport = TypedDict( 75 | "_JinjaImport", 76 | { 77 | "module": str, 78 | "item": Optional[str], 79 | }, 80 | ) 81 | 82 | _FromFile = TypedDict( 83 | "_FromFile", 84 | { 85 | "source": Optional[str], 86 | "pattern": Optional[str], 87 | }, 88 | ) 89 | 90 | _Bump = TypedDict( 91 | "_Bump", 92 | { 93 | "enable": bool, 94 | "index": int, 95 | }, 96 | ) 97 | 98 | _Config = TypedDict( 99 | "_Config", 100 | { 101 | "enable": bool, 102 | "vcs": str, 103 | "dirty": bool, 104 | "pattern": Optional[str], 105 | "pattern-prefix": Optional[str], 106 | "latest-tag": bool, 107 | "substitution": _Substitution, 108 | "files": Mapping[str, _File], 109 | "style": Optional[str], 110 | "metadata": Optional[bool], 111 | "format": Optional[str], 112 | "format-jinja": Optional[str], 113 | "format-jinja-imports": Sequence[_JinjaImport], 114 | "bump": Union[bool, _Bump], 115 | "tagged-metadata": bool, 116 | "full-commit": bool, 117 | "tag-branch": Optional[str], 118 | "tag-dir": str, 119 | "strict": bool, 120 | "fix-shallow-repository": bool, 121 | "ignore-untracked": bool, 122 | "commit-length": Optional[int], 123 | "from-file": _FromFile, 124 | }, 125 | ) 126 | else: 127 | _SubstitutionPattern = Mapping 128 | _SubstitutionFolder = Mapping 129 | _Substitution = Mapping 130 | _File = Mapping 131 | _JinjaImport = Mapping 132 | _FromFile = Mapping 133 | _Bump = Mapping 134 | _Config = Mapping 135 | 136 | 137 | class _Mode(Enum): 138 | Classic = "classic" 139 | Pep621 = "pep621" 140 | 141 | 142 | class _ProjectState: 143 | def __init__( 144 | self, 145 | path: Path, 146 | original_version: Optional[str], 147 | version: str, 148 | mode: _Mode, 149 | dynamic_array: Optional[tomlkit.items.Array], 150 | substitutions: Optional[MutableMapping[Path, str]] = None, 151 | ) -> None: 152 | self.path = path 153 | self.original_version = original_version 154 | self.version = version 155 | self.mode = mode 156 | self.dynamic_array = dynamic_array 157 | self.substitutions = {} if substitutions is None else substitutions # type: MutableMapping[Path, str] 158 | 159 | 160 | class _State: 161 | def __init__(self) -> None: 162 | self.patched_core_poetry_create = False 163 | self.cli_mode = False 164 | self.projects = {} # type: MutableMapping[str, _ProjectState] 165 | 166 | 167 | _state = _State() 168 | 169 | 170 | class _SubPattern: 171 | def __init__(self, value: str, mode: str): 172 | self.value = value 173 | self.mode = mode 174 | 175 | @staticmethod 176 | def from_config(config: Sequence[Union[str, Mapping]]) -> Sequence["_SubPattern"]: 177 | patterns = [] 178 | 179 | for x in config: 180 | if isinstance(x, str): 181 | patterns.append(_SubPattern(x, mode="str")) 182 | else: 183 | patterns.append(_SubPattern(x["value"], mode=x.get("mode", "str"))) 184 | 185 | return patterns 186 | 187 | 188 | class _FolderConfig: 189 | def __init__(self, path: Path, files: Sequence[str], patterns: Sequence[_SubPattern]): 190 | self.path = path 191 | self.files = files 192 | self.patterns = patterns 193 | 194 | @staticmethod 195 | def from_config(config: _Config, root: Path) -> Sequence["_FolderConfig"]: 196 | files = config["substitution"]["files"] 197 | patterns = _SubPattern.from_config(config["substitution"]["patterns"]) 198 | 199 | main = _FolderConfig(root, files, patterns) 200 | extra = [ 201 | _FolderConfig( 202 | root / x["path"], 203 | x["files"] if x["files"] is not None else files, 204 | _SubPattern.from_config(x["patterns"]) if x["patterns"] is not None else patterns, 205 | ) 206 | for x in config["substitution"]["folders"] 207 | ] 208 | 209 | return [main, *extra] 210 | 211 | 212 | class _BumpConfig: 213 | def __init__(self, enable: bool, index: int): 214 | self.enable = enable 215 | self.index = index 216 | 217 | @staticmethod 218 | def from_config(config: Union[bool, _Bump]) -> "_BumpConfig": 219 | if isinstance(config, bool): 220 | return _BumpConfig(config, -1) 221 | else: 222 | return _BumpConfig(config["enable"], config["index"]) 223 | 224 | 225 | def _default_config() -> Mapping: 226 | return { 227 | "tool": { 228 | "poetry-dynamic-versioning": { 229 | "enable": False, 230 | "vcs": "any", 231 | "dirty": False, 232 | "pattern": None, 233 | "pattern-prefix": None, 234 | "latest-tag": False, 235 | "substitution": { 236 | "files": ["*.py", "*/__init__.py", "*/__version__.py", "*/_version.py"], 237 | "patterns": [ 238 | r"(^__version__\s*(?::.*?)?=\s*['\"])[^'\"]*(['\"])", 239 | { 240 | "value": r"(^__version_tuple__\s*(?::.*?)?=\s*\()[^)]*(\))", 241 | "mode": "tuple", 242 | }, 243 | ], 244 | "folders": [], 245 | }, 246 | "files": {}, 247 | "style": None, 248 | "metadata": None, 249 | "format": None, 250 | "format-jinja": None, 251 | "format-jinja-imports": [], 252 | "bump": False, 253 | "tagged-metadata": False, 254 | "full-commit": False, 255 | "tag-branch": None, 256 | "tag-dir": "tags", 257 | "strict": False, 258 | "fix-shallow-repository": False, 259 | "ignore-untracked": False, 260 | "commit-length": None, 261 | "from-file": { 262 | "source": None, 263 | "pattern": None, 264 | }, 265 | } 266 | } 267 | } 268 | 269 | 270 | def _deep_merge_dicts(base: Mapping, addition: Mapping) -> Mapping: 271 | result = dict(copy.deepcopy(base)) 272 | for key, value in addition.items(): 273 | if isinstance(value, dict) and key in base and isinstance(base[key], dict): 274 | result[key] = _deep_merge_dicts(base[key], value) 275 | else: 276 | result[key] = value 277 | return result 278 | 279 | 280 | def _debug(message: str) -> None: 281 | enabled = os.environ.get(_DEBUG_ENV) == "1" 282 | 283 | if enabled: 284 | print(message, file=sys.stderr) 285 | 286 | 287 | def _find_higher_file(*names: str, start: Optional[Path] = None) -> Optional[Path]: 288 | # Note: We need to make sure we get a pathlib object. Many tox poetry 289 | # helpers will pass us a string and not a pathlib object. See issue #40. 290 | if start is None: 291 | start = Path.cwd() 292 | elif not isinstance(start, Path): 293 | start = Path(start) 294 | for level in [start, *start.parents]: 295 | for name in names: 296 | if (level / name).is_file(): 297 | return level / name 298 | return None 299 | 300 | 301 | def _get_pyproject_path(start: Optional[Path] = None) -> Optional[Path]: 302 | return _find_higher_file("pyproject.toml", start=start) 303 | 304 | 305 | def _get_pyproject_path_from_poetry(pyproject) -> Path: 306 | # poetry-core 1.6.0+: 307 | recommended = getattr(pyproject, "path", None) 308 | # poetry-core <1.6.0: 309 | legacy = getattr(pyproject, "file", None) 310 | 311 | if recommended: 312 | return recommended 313 | elif legacy: 314 | return legacy 315 | else: 316 | raise RuntimeError("Unable to determine pyproject.toml path from Poetry instance") 317 | 318 | 319 | def _get_config(local: Mapping) -> _Config: 320 | def initialize(data, key): 321 | if isinstance(data, dict) and key not in data: 322 | data[key] = None 323 | 324 | if isinstance(local, tomlkit.TOMLDocument): 325 | local = local.unwrap() 326 | 327 | merged = _deep_merge_dicts(_default_config(), local)["tool"]["poetry-dynamic-versioning"] # type: _Config 328 | 329 | # Add default values so we don't have to worry about missing keys 330 | for x in merged["files"].values(): 331 | initialize(x, "initial-content") 332 | initialize(x, "initial-content-jinja") 333 | initialize(x, "persistent-substitution") 334 | for x in merged["format-jinja-imports"]: 335 | initialize(x, "item") 336 | for x in merged["substitution"]["folders"]: 337 | initialize(x, "files") 338 | initialize(x, "patterns") 339 | for x in merged["substitution"]["patterns"]: 340 | initialize(x, "mode") 341 | 342 | return merged 343 | 344 | 345 | def _get_config_from_path(start: Optional[Path] = None) -> Mapping: 346 | pyproject_path = _get_pyproject_path(start) 347 | if pyproject_path is None: 348 | return _default_config()["tool"]["poetry-dynamic-versioning"] 349 | pyproject = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) 350 | result = _get_config(pyproject) 351 | return result 352 | 353 | 354 | def _validate_config(config: Optional[Mapping] = None) -> Sequence[str]: 355 | if config is None: 356 | pyproject_path = _get_pyproject_path() 357 | if pyproject_path is None: 358 | raise RuntimeError("Unable to find pyproject.toml") 359 | config = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) 360 | 361 | return _validate_config_section( 362 | config.get("tool", {}).get("poetry-dynamic-versioning", {}), 363 | _default_config()["tool"]["poetry-dynamic-versioning"], 364 | ["tool", "poetry-dynamic-versioning"], 365 | ) 366 | 367 | 368 | def _validate_config_section(config: Mapping, default: Mapping, path: Sequence[str]) -> Sequence[str]: 369 | if not default: 370 | return [] 371 | 372 | errors = [] 373 | 374 | for (key, value) in config.items(): 375 | if key not in default: 376 | escaped_key = key if "." not in key else '"{}"'.format(key) 377 | errors.append("Unknown key: " + ".".join([*path, escaped_key])) 378 | elif isinstance(value, dict) and isinstance(config.get(key), dict): 379 | errors.extend(_validate_config_section(config[key], default[key], [*path, key])) 380 | 381 | return errors 382 | 383 | 384 | def _escape_branch(value: Optional[str]) -> Optional[str]: 385 | if value is None: 386 | return None 387 | return re.sub(r"[^a-zA-Z0-9]", "", value) 388 | 389 | 390 | def _format_timestamp(value: Optional[dt.datetime]) -> Optional[str]: 391 | if value is None: 392 | return None 393 | return value.strftime("%Y%m%d%H%M%S") 394 | 395 | 396 | def _render_jinja(version: Version, template: str, config: _Config, extra: Optional[Mapping] = None) -> str: 397 | if extra is None: 398 | extra = {} 399 | 400 | bump_config = _BumpConfig.from_config(config["bump"]) 401 | 402 | if bump_config.enable and version.distance > 0: 403 | version = version.bump(index=bump_config.index) 404 | 405 | def base_part(index: int) -> int: 406 | parts = version.base.split(".") 407 | try: 408 | return int(parts[index]) 409 | except Exception: 410 | return 0 411 | 412 | default_context = { 413 | "base": version.base, 414 | "version": version, 415 | "stage": version.stage, 416 | "revision": version.revision, 417 | "distance": version.distance, 418 | "commit": version.commit, 419 | "dirty": version.dirty, 420 | "branch": version.branch, 421 | "branch_escaped": _escape_branch(version.branch), 422 | "timestamp": _format_timestamp(version.timestamp), 423 | "major": base_part(0), 424 | "minor": base_part(1), 425 | "patch": base_part(2), 426 | "env": os.environ, 427 | "bump_version": bump_version, 428 | "tagged_metadata": version.tagged_metadata, 429 | "serialize_pep440": serialize_pep440, 430 | "serialize_pvp": serialize_pvp, 431 | "serialize_semver": serialize_semver, 432 | **extra, 433 | } 434 | custom_context = {} # type: dict 435 | for entry in config["format-jinja-imports"]: 436 | if "module" in entry: 437 | module = import_module(entry["module"]) 438 | if entry["item"] is not None: 439 | custom_context[entry["item"]] = getattr(module, entry["item"]) 440 | else: 441 | custom_context[entry["module"]] = module 442 | serialized = jinja2.Template(template).render(**default_context, **custom_context) 443 | return serialized 444 | 445 | 446 | def _run_cmd(command: str, codes: Sequence[int] = (0,)) -> Tuple[int, str]: 447 | result = subprocess.run( 448 | shlex.split(command), 449 | stdout=subprocess.PIPE, 450 | stderr=subprocess.STDOUT, 451 | ) 452 | output = result.stdout.decode().strip() 453 | if codes and result.returncode not in codes: 454 | raise RuntimeError("The command '{}' returned code {}. Output:\n{}".format(command, result.returncode, output)) 455 | return (result.returncode, output) 456 | 457 | 458 | def _get_override_version(name: Optional[str], env: Optional[Mapping] = None) -> Optional[str]: 459 | env = env if env is not None else os.environ 460 | 461 | if name is not None: 462 | raw_overrides = env.get(_OVERRIDE_ENV) 463 | if raw_overrides is not None: 464 | pairs = raw_overrides.split(",") 465 | for pair in pairs: 466 | if "=" not in pair: 467 | continue 468 | k, v = pair.split("=", 1) 469 | if k.strip() == name: 470 | return v.strip() 471 | 472 | bypass = env.get(_BYPASS_ENV) 473 | if bypass is not None: 474 | return bypass 475 | 476 | return None 477 | 478 | 479 | def _get_version_from_file(config: _Config) -> Optional[str]: 480 | source = config["from-file"]["source"] 481 | pattern = config["from-file"]["pattern"] 482 | 483 | if source is None: 484 | return None 485 | 486 | pyproject_path = _get_pyproject_path() 487 | if pyproject_path is None: 488 | raise RuntimeError("Unable to find pyproject.toml") 489 | 490 | content = pyproject_path.parent.joinpath(source).read_bytes().decode("utf-8").strip() 491 | 492 | if pattern is None: 493 | return content 494 | 495 | result = re.search(pattern, content, re.MULTILINE) 496 | if result is None: 497 | raise ValueError("File '{}' did not contain a match for '{}'".format(source, pattern)) 498 | return result.group(1) 499 | 500 | 501 | def _get_version_from_dunamai( 502 | vcs: Vcs, pattern: Union[str, Pattern], config: _Config, *, strict: Optional[bool] = None 503 | ) -> Version: 504 | return Version.from_vcs( 505 | vcs=vcs, 506 | pattern=pattern, 507 | latest_tag=config["latest-tag"], 508 | tag_dir=config["tag-dir"], 509 | tag_branch=config["tag-branch"], 510 | full_commit=config["full-commit"], 511 | strict=config["strict"] if strict is None else strict, 512 | pattern_prefix=config["pattern-prefix"], 513 | ignore_untracked=config["ignore-untracked"], 514 | commit_length=config["commit-length"], 515 | ) 516 | 517 | 518 | def _get_version(config: _Config, name: Optional[str] = None) -> Tuple[str, Version]: 519 | override = _get_override_version(name) 520 | if override is not None: 521 | return (override, Version.parse(override)) 522 | 523 | override = _get_version_from_file(config) 524 | if override is not None: 525 | return (override, Version.parse(override)) 526 | 527 | vcs = Vcs(config["vcs"]) 528 | style = Style(config["style"]) if config["style"] is not None else None 529 | 530 | pattern = config["pattern"] if config["pattern"] is not None else Pattern.Default # type: Union[str, Pattern] 531 | 532 | if config["fix-shallow-repository"]: 533 | # We start without strict so we can inspect the concerns. 534 | version = _get_version_from_dunamai(vcs, pattern, config, strict=False) 535 | retry = config["strict"] 536 | 537 | if Concern.ShallowRepository in version.concerns and version.vcs == Vcs.Git: 538 | retry = True 539 | _run_cmd("git fetch --unshallow") 540 | 541 | if retry: 542 | version = _get_version_from_dunamai(vcs, pattern, config) 543 | else: 544 | version = _get_version_from_dunamai(vcs, pattern, config) 545 | 546 | for concern in version.concerns: 547 | print("Warning: {}".format(concern.message()), file=sys.stderr) 548 | 549 | if config["format-jinja"]: 550 | serialized = _render_jinja(version, config["format-jinja"], config) 551 | if style is not None: 552 | check_version(serialized, style) 553 | else: 554 | bump_config = _BumpConfig.from_config(config["bump"]) 555 | if bump_config.enable: 556 | updated = version.bump(index=bump_config.index, smart=True) 557 | else: 558 | updated = version 559 | 560 | serialized = updated.serialize( 561 | metadata=config["metadata"], 562 | dirty=config["dirty"], 563 | format=config["format"], 564 | style=style, 565 | tagged_metadata=config["tagged-metadata"], 566 | ) 567 | 568 | return (serialized, version) 569 | 570 | 571 | def _substitute_version(name: str, version: str, folders: Sequence[_FolderConfig]) -> None: 572 | if _state.projects[name].substitutions: 573 | # Already ran; don't need to repeat. 574 | return 575 | 576 | files = {} # type: MutableMapping[Path, _FolderConfig] 577 | for folder in folders: 578 | for file_glob in folder.files: 579 | i = 0 580 | 581 | # call str() since file_glob here could be a non-internable string 582 | for match in folder.path.glob(str(file_glob)): 583 | i += 1 584 | resolved = match.resolve() 585 | if resolved in files: 586 | continue 587 | files[resolved] = folder 588 | 589 | if i == 0: 590 | _debug("No files found for substitution with glob '{}' in folder '{}'".format(file_glob, folder.path)) 591 | 592 | for file, config in files.items(): 593 | original_content = file.read_bytes().decode("utf-8") 594 | new_content = _substitute_version_in_text(version, original_content, config.patterns) 595 | if original_content != new_content: 596 | _state.projects[name].substitutions[file] = original_content 597 | file.write_bytes(new_content.encode("utf-8")) 598 | else: 599 | _debug("No changes made during substitution in file '{}'".format(file)) 600 | 601 | 602 | def _substitute_version_in_text(version: str, content: str, patterns: Sequence[_SubPattern]) -> str: 603 | new_content = content 604 | 605 | for pattern in patterns: 606 | if pattern.mode == "str": 607 | insert = version 608 | elif pattern.mode == "tuple": 609 | parts = [] 610 | split = version.split("+", 1) 611 | split = [*re.split(r"[-.]", split[0]), *split[1:]] 612 | for part in split: 613 | if part == "": 614 | continue 615 | try: 616 | parts.append(str(int(part))) 617 | except ValueError: 618 | parts.append('"{}"'.format(part)) 619 | insert = ", ".join(parts) 620 | if len(parts) == 1: 621 | insert += "," 622 | else: 623 | raise ValueError("Invalid substitution mode: {}".format(pattern.mode)) 624 | 625 | new_content = re.sub(pattern.value, r"\g<1>{}\g<2>".format(insert), new_content, flags=re.MULTILINE) 626 | 627 | return new_content 628 | 629 | 630 | def _apply_version( 631 | name: str, 632 | version: str, 633 | instance: Version, 634 | config: _Config, 635 | pyproject_path: Path, 636 | mode: _Mode, 637 | retain: bool = False, 638 | ) -> None: 639 | pyproject = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) 640 | 641 | if mode == _Mode.Classic: 642 | pyproject["tool"]["poetry"]["version"] = version # type: ignore 643 | elif mode == _Mode.Pep621: 644 | if "version" in pyproject["project"]["dynamic"]: # type: ignore 645 | pyproject["project"]["dynamic"].remove("version") # type: ignore 646 | pyproject["project"]["version"] = version # type: ignore 647 | if "version" in pyproject["tool"]["poetry"]: # type: ignore 648 | pyproject["tool"]["poetry"].pop("version") # type: ignore 649 | 650 | # Disable the plugin in case we're building a source distribution, 651 | # which won't have access to the VCS info at install time. 652 | # We revert this later when we deactivate. 653 | if not retain and not _state.cli_mode: 654 | pyproject["tool"]["poetry-dynamic-versioning"]["enable"] = False # type: ignore 655 | 656 | pyproject_path.write_bytes(tomlkit.dumps(pyproject).encode("utf-8")) 657 | 658 | for file_name, file_info in config["files"].items(): 659 | full_file = pyproject_path.parent.joinpath(file_name) 660 | 661 | if file_info["initial-content-jinja"] is not None: 662 | if not full_file.parent.exists(): 663 | full_file.parent.mkdir() 664 | initial = textwrap.dedent( 665 | _render_jinja( 666 | instance, 667 | file_info["initial-content-jinja"], 668 | config, 669 | {"formatted_version": version}, 670 | ) 671 | ) 672 | full_file.write_bytes(initial.encode("utf-8")) 673 | elif file_info["initial-content"] is not None: 674 | if not full_file.parent.exists(): 675 | full_file.parent.mkdir() 676 | initial = textwrap.dedent(file_info["initial-content"]) 677 | full_file.write_bytes(initial.encode("utf-8")) 678 | 679 | _substitute_version( 680 | name, # type: ignore 681 | version, 682 | _FolderConfig.from_config(config, pyproject_path.parent), 683 | ) 684 | 685 | 686 | def _get_and_apply_version( 687 | pyproject_path: Optional[Path] = None, 688 | retain: bool = False, 689 | force: bool = False, 690 | io: bool = True, 691 | ) -> Optional[str]: 692 | if pyproject_path is None: 693 | pyproject_path = _get_pyproject_path() 694 | if pyproject_path is None: 695 | raise RuntimeError("Unable to find pyproject.toml") 696 | 697 | # The actual type is `tomlkit.TOMLDocument`, which is important to preserve formatting, 698 | # but it also causes a lot of type-checking noise. 699 | pyproject = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) # type: Mapping 700 | 701 | classic = "tool" in pyproject and "poetry" in pyproject["tool"] and "name" in pyproject["tool"]["poetry"] 702 | pep621 = ( 703 | "project" in pyproject 704 | and "name" in pyproject["project"] 705 | and "dynamic" in pyproject["project"] 706 | and "version" in pyproject["project"]["dynamic"] 707 | and "version" not in pyproject["project"] 708 | and "tool" in pyproject 709 | and "poetry" in pyproject["tool"] 710 | and "version" in pyproject["tool"]["poetry"] 711 | ) 712 | 713 | if classic: 714 | name = pyproject["tool"]["poetry"]["name"] 715 | original = pyproject["tool"]["poetry"]["version"] 716 | dynamic_array = None 717 | elif pep621: 718 | name = pyproject["project"]["name"] 719 | original = pyproject["tool"]["poetry"]["version"] 720 | dynamic_array = pyproject["project"]["dynamic"] 721 | else: 722 | return None 723 | 724 | if name in _state.projects: 725 | return name 726 | 727 | config = _get_config(pyproject) 728 | if not config["enable"] and not force: 729 | return name if name in _state.projects else None 730 | 731 | initial_dir = Path.cwd() 732 | target_dir = pyproject_path.parent 733 | os.chdir(str(target_dir)) 734 | try: 735 | version, instance = _get_version(config, name) 736 | finally: 737 | os.chdir(str(initial_dir)) 738 | 739 | if classic and name is not None and original is not None: 740 | mode = _Mode.Classic 741 | _state.projects[name] = _ProjectState(pyproject_path, original, version, mode, dynamic_array) 742 | if io: 743 | _apply_version(name, version, instance, config, pyproject_path, mode, retain) 744 | elif pep621 and name is not None: 745 | mode = _Mode.Pep621 746 | _state.projects[name] = _ProjectState(pyproject_path, original, version, mode, dynamic_array) 747 | if io: 748 | _apply_version(name, version, instance, config, pyproject_path, mode, retain) 749 | 750 | return name 751 | 752 | 753 | def _revert_version(retain: bool = False) -> None: 754 | for project, state in _state.projects.items(): 755 | pyproject = tomlkit.parse(state.path.read_bytes().decode("utf-8")) 756 | 757 | if state.substitutions: 758 | config = _get_config(pyproject) 759 | 760 | persistent = [] 761 | for file, file_info in config["files"].items(): 762 | if file_info["persistent-substitution"]: 763 | persistent.append(state.path.parent.joinpath(file)) 764 | 765 | for file, content in state.substitutions.items(): 766 | if file in persistent: 767 | continue 768 | 769 | file.write_bytes(content.encode("utf-8")) 770 | 771 | # Reread pyproject.toml in case the substitutions affected it. 772 | pyproject = tomlkit.parse(state.path.read_bytes().decode("utf-8")) 773 | 774 | if state.mode == _Mode.Classic: 775 | if state.original_version is not None: 776 | pyproject["tool"]["poetry"]["version"] = state.original_version # type: ignore 777 | elif state.mode == _Mode.Pep621: 778 | if state.dynamic_array is not None: 779 | pyproject["project"]["dynamic"] = state.dynamic_array # type: ignore 780 | if "version" in pyproject["project"]: # type: ignore 781 | pyproject["project"].pop("version") # type: ignore 782 | if state.original_version is not None: 783 | pyproject["tool"]["poetry"]["version"] = state.original_version # type: ignore 784 | 785 | if not retain and not _state.cli_mode: 786 | pyproject["tool"]["poetry-dynamic-versioning"]["enable"] = True # type: ignore 787 | 788 | state.path.write_bytes(tomlkit.dumps(pyproject).encode("utf-8")) 789 | 790 | _state.projects.clear() 791 | -------------------------------------------------------------------------------- /poetry_dynamic_versioning/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from poetry_dynamic_versioning import ( 4 | _state, 5 | cli, 6 | ) 7 | 8 | 9 | def main() -> None: 10 | try: 11 | _state.cli_mode = True 12 | args = cli.parse_args() 13 | 14 | if args.cmd is None: 15 | cli.apply(standalone=True) 16 | elif args.cmd == cli.Command.enable: 17 | cli.enable() 18 | elif args.cmd == cli.Command.show: 19 | cli.show() 20 | except Exception as e: 21 | print("Error: {}".format(e), file=sys.stderr) 22 | sys.exit(1) 23 | -------------------------------------------------------------------------------- /poetry_dynamic_versioning/backend.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from poetry.core.masonry.api import * 3 | import poetry_dynamic_versioning.patch as patch 4 | 5 | patch.activate() 6 | -------------------------------------------------------------------------------- /poetry_dynamic_versioning/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from typing import ( 4 | Mapping, 5 | Optional, 6 | ) 7 | 8 | import tomlkit 9 | 10 | from poetry_dynamic_versioning import ( 11 | _get_and_apply_version, 12 | _get_config, 13 | _get_override_version, 14 | _get_pyproject_path, 15 | _get_version, 16 | _state, 17 | _validate_config, 18 | ) 19 | 20 | _DEFAULT_REQUIRES = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 21 | _DEFAULT_BUILD_BACKEND = "poetry_dynamic_versioning.backend" 22 | 23 | 24 | class Key: 25 | tool = "tool" 26 | pdv = "poetry-dynamic-versioning" 27 | enable = "enable" 28 | build_system = "build-system" 29 | requires = "requires" 30 | build_backend = "build-backend" 31 | project = "project" 32 | poetry = "poetry" 33 | dynamic = "dynamic" 34 | version = "version" 35 | name = "name" 36 | 37 | 38 | class Command: 39 | dv = "dynamic-versioning" 40 | enable = "enable" 41 | show = "show" 42 | dv_enable = "{} {}".format(dv, enable) 43 | dv_show = "{} {}".format(dv, show) 44 | 45 | 46 | class Help: 47 | main = ( 48 | "Apply the dynamic version to all relevant files and leave the changes in-place." 49 | " This allows you to activate the plugin behavior on demand and inspect the result." 50 | " Your configuration will be detected from pyproject.toml as normal." 51 | ) 52 | enable = ( 53 | "Update pyproject.toml to enable the plugin using a typical configuration." 54 | " The output may not be suitable for more complex use cases." 55 | ) 56 | show = "Print the version without changing any files." 57 | 58 | 59 | def get_parser() -> argparse.ArgumentParser: 60 | parser = argparse.ArgumentParser(description=Help.main) 61 | 62 | subparsers = parser.add_subparsers(dest="cmd", title="subcommands") 63 | subparsers.add_parser(Command.enable, help=Help.enable) 64 | subparsers.add_parser(Command.show, help=Help.show) 65 | 66 | return parser 67 | 68 | 69 | def parse_args(argv=None) -> argparse.Namespace: 70 | return get_parser().parse_args(argv) 71 | 72 | 73 | def validate(*, standalone: bool, config: Optional[Mapping] = None) -> None: 74 | errors = _validate_config(config) 75 | if errors: 76 | if standalone: 77 | print("Configuration issues:", file=sys.stderr) 78 | else: 79 | print("poetry-dynamic-versioning configuration issues:", file=sys.stderr) 80 | for error in errors: 81 | print(" - {}".format(error), file=sys.stderr) 82 | 83 | 84 | def apply(*, standalone: bool) -> None: 85 | validate(standalone=standalone) 86 | 87 | name = _get_and_apply_version(retain=True, force=True) 88 | if not name: 89 | raise RuntimeError("Unable to determine a dynamic version") 90 | 91 | if standalone: 92 | report_apply(name) 93 | 94 | 95 | def report_apply(name: str) -> None: 96 | print("Version: {}".format(_state.projects[name].version), file=sys.stderr) 97 | if _state.projects[name].substitutions: 98 | print("Files with substitutions:", file=sys.stderr) 99 | for file_name in _state.projects[name].substitutions: 100 | print(" - {}".format(file_name), file=sys.stderr) 101 | else: 102 | print("Files with substitutions: none", file=sys.stderr) 103 | 104 | 105 | def enable() -> None: 106 | pyproject_path = _get_pyproject_path() 107 | if pyproject_path is None: 108 | raise RuntimeError("Unable to find pyproject.toml") 109 | config = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) 110 | 111 | config = _enable_in_doc(config) 112 | pyproject_path.write_bytes(tomlkit.dumps(config).encode("utf-8")) 113 | 114 | 115 | def _enable_in_doc(doc: tomlkit.TOMLDocument, env: Optional[Mapping] = None) -> tomlkit.TOMLDocument: 116 | name = doc.get(Key.project, {}).get(Key.name) or doc.get(Key.tool, {}).get(Key.poetry, {}).get(Key.name) 117 | placeholder_version = _get_override_version(name, env) or "0.0.0" 118 | 119 | pdv_table = tomlkit.table().add(Key.enable, True) 120 | tool_table = tomlkit.table().add(Key.pdv, pdv_table) 121 | 122 | if doc.get(Key.tool) is None: 123 | doc[Key.tool] = tool_table 124 | elif doc[Key.tool].get(Key.pdv) is None: # type: ignore 125 | doc[Key.tool][Key.pdv] = pdv_table # type: ignore 126 | else: 127 | doc[Key.tool][Key.pdv].update(pdv_table) # type: ignore 128 | 129 | build_system_table = ( 130 | tomlkit.table().add(Key.requires, _DEFAULT_REQUIRES).add(Key.build_backend, _DEFAULT_BUILD_BACKEND) 131 | ) 132 | 133 | if doc.get(Key.build_system) is None: 134 | doc[Key.build_system] = build_system_table 135 | else: 136 | doc[Key.build_system].update(build_system_table) # type: ignore 137 | 138 | # Poetry 2.0.0+ 139 | if doc.get(Key.project) is not None: 140 | if doc[Key.project].get(Key.version) is not None: 141 | del doc[Key.project][Key.version] 142 | 143 | if doc[Key.project].get(Key.dynamic) is None: 144 | doc[Key.project][Key.dynamic] = [Key.version] 145 | elif Key.version not in doc[Key.project][Key.dynamic]: 146 | doc[Key.project][Key.dynamic].append(Key.version) 147 | 148 | if doc[Key.tool].get(Key.poetry) is None: 149 | doc[Key.tool][Key.poetry] = tomlkit.table().add(Key.version, placeholder_version) 150 | elif doc[Key.tool][Key.poetry].get(Key.version) is None: 151 | doc[Key.tool][Key.poetry][Key.version] = placeholder_version 152 | 153 | return doc 154 | 155 | 156 | def show() -> None: 157 | pyproject_path = _get_pyproject_path() 158 | if pyproject_path is None: 159 | raise RuntimeError("Unable to find pyproject.toml") 160 | 161 | pyproject = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) 162 | config = _get_config(pyproject) 163 | version = _get_version(config) 164 | 165 | print(version[0]) 166 | -------------------------------------------------------------------------------- /poetry_dynamic_versioning/patch.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import functools 3 | 4 | from poetry_dynamic_versioning import ( 5 | _revert_version, 6 | _get_and_apply_version, 7 | _get_config_from_path, 8 | _get_pyproject_path_from_poetry, 9 | _state, 10 | ) 11 | 12 | 13 | def _patch_poetry_create(factory_mod) -> None: 14 | try: 15 | from poetry.core.constraints.version import Version as PoetryVersion 16 | except ImportError: 17 | from poetry.core.semver.version import Version as PoetryVersion 18 | 19 | original_poetry_create = getattr(factory_mod, "Factory").create_poetry 20 | 21 | @functools.wraps(original_poetry_create) 22 | def alt_poetry_create(cls, *args, **kwargs): 23 | instance = original_poetry_create(cls, *args, **kwargs) 24 | 25 | if not _state.cli_mode: 26 | name = _get_and_apply_version( 27 | pyproject_path=_get_pyproject_path_from_poetry(instance.pyproject), 28 | ) 29 | if name: 30 | version = _state.projects[name].version 31 | instance._package._version = PoetryVersion.parse(version) 32 | instance._package._pretty_version = version 33 | 34 | return instance 35 | 36 | getattr(factory_mod, "Factory").create_poetry = alt_poetry_create 37 | 38 | 39 | def _apply_patches() -> None: 40 | if not _state.patched_core_poetry_create: 41 | from poetry.core import factory as factory_mod 42 | 43 | _patch_poetry_create(factory_mod) 44 | _state.patched_core_poetry_create = True 45 | 46 | 47 | def activate() -> None: 48 | config = _get_config_from_path() 49 | if not config["enable"]: 50 | return 51 | 52 | _apply_patches() 53 | atexit.register(deactivate) 54 | 55 | 56 | def deactivate() -> None: 57 | if not _state.cli_mode: 58 | _revert_version() 59 | -------------------------------------------------------------------------------- /poetry_dynamic_versioning/plugin.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "DynamicVersioningCommand", 3 | "DynamicVersioningPlugin", 4 | ] 5 | 6 | import functools 7 | import os 8 | 9 | from cleo.commands.command import Command 10 | from cleo.events.console_command_event import ConsoleCommandEvent 11 | from cleo.events.event_dispatcher import EventDispatcher 12 | from cleo.events.console_events import COMMAND, SIGNAL, TERMINATE, ERROR 13 | from packaging.version import Version as PackagingVersion 14 | from poetry.core import __version__ as poetry_core_version 15 | from poetry.core.poetry import Poetry 16 | from poetry.core.factory import Factory 17 | from poetry.console.application import Application 18 | from poetry.plugins.application_plugin import ApplicationPlugin 19 | 20 | if PackagingVersion(poetry_core_version) >= PackagingVersion("1.3.0"): 21 | from poetry.core.constraints.version import Version as PoetryCoreVersion 22 | else: 23 | from poetry.core.semver.version import Version as PoetryCoreVersion 24 | 25 | 26 | from poetry_dynamic_versioning import ( 27 | cli, 28 | _get_config, 29 | _get_and_apply_version, 30 | _get_pyproject_path_from_poetry, 31 | _state, 32 | _revert_version, 33 | ) 34 | 35 | _COMMAND_ENV = "POETRY_DYNAMIC_VERSIONING_COMMANDS" 36 | _COMMAND_NO_IO_ENV = "POETRY_DYNAMIC_VERSIONING_COMMANDS_NO_IO" 37 | 38 | 39 | def _patch_dependency_versions(io: bool) -> None: 40 | """ 41 | The plugin system doesn't seem to expose a way to change dependency 42 | versions, so we patch `Factory.create_poetry()` to do the work there. 43 | """ 44 | if _state.patched_core_poetry_create: 45 | return 46 | 47 | original_create_poetry = Factory.create_poetry 48 | 49 | @functools.wraps(Factory.create_poetry) 50 | def patched_create_poetry(*args, **kwargs): 51 | instance = original_create_poetry(*args, **kwargs) 52 | _apply_version_via_plugin(instance, io=io) 53 | return instance 54 | 55 | Factory.create_poetry = patched_create_poetry 56 | _state.patched_core_poetry_create = True 57 | 58 | 59 | def _should_apply(command: str) -> bool: 60 | override = os.environ.get(_COMMAND_ENV) 61 | if override is not None: 62 | return command in override.split(",") 63 | else: 64 | return command not in ["run", "shell", cli.Command.dv, cli.Command.dv_enable, cli.Command.dv_show] 65 | 66 | 67 | def _should_apply_with_io(command: str) -> bool: 68 | override = os.environ.get(_COMMAND_NO_IO_ENV) 69 | if override is not None: 70 | return command not in override.split(",") 71 | else: 72 | return command not in ["version"] 73 | 74 | 75 | def _apply_version_via_plugin( 76 | poetry: Poetry, 77 | retain: bool = False, 78 | force: bool = False, 79 | standalone: bool = False, 80 | # fmt: off 81 | io: bool = True 82 | # fmt: on 83 | ) -> None: 84 | name = _get_and_apply_version( 85 | pyproject_path=_get_pyproject_path_from_poetry(poetry.pyproject), 86 | retain=retain, 87 | force=force, 88 | io=io, 89 | ) 90 | if name: 91 | version = _state.projects[name].version 92 | 93 | # Would be nice to use `.set_version()`, but it's only available on 94 | # Poetry's `ProjectPackage`, not poetry-core's `ProjectPackage`. 95 | poetry._package._version = PoetryCoreVersion.parse(version) 96 | poetry._package._pretty_version = version 97 | 98 | if standalone: 99 | cli.report_apply(name) 100 | 101 | 102 | class DynamicVersioningCommand(Command): 103 | name = cli.Command.dv 104 | description = cli.Help.main 105 | 106 | def __init__(self, application: Application): 107 | super().__init__() 108 | self._application = application 109 | 110 | def handle(self) -> int: 111 | _state.cli_mode = True 112 | _apply_version_via_plugin(self._application.poetry, retain=True, force=True, standalone=True) 113 | return 0 114 | 115 | 116 | class DynamicVersioningEnableCommand(Command): 117 | name = cli.Command.dv_enable 118 | description = cli.Help.enable 119 | 120 | def __init__(self, application: Application): 121 | super().__init__() 122 | self._application = application 123 | 124 | def handle(self) -> int: 125 | _state.cli_mode = True 126 | cli.enable() 127 | return 0 128 | 129 | 130 | class DynamicVersioningShowCommand(Command): 131 | name = cli.Command.dv_show 132 | description = cli.Help.show 133 | 134 | def __init__(self, application: Application): 135 | super().__init__() 136 | self._application = application 137 | 138 | def handle(self) -> int: 139 | _state.cli_mode = True 140 | cli.show() 141 | return 0 142 | 143 | 144 | class DynamicVersioningPlugin(ApplicationPlugin): 145 | def __init__(self): 146 | self._application = None 147 | 148 | def activate(self, application: Application) -> None: 149 | self._application = application 150 | 151 | application.command_loader.register_factory(cli.Command.dv, lambda: DynamicVersioningCommand(application)) 152 | application.command_loader.register_factory( 153 | cli.Command.dv_enable, lambda: DynamicVersioningEnableCommand(application) 154 | ) 155 | application.command_loader.register_factory( 156 | cli.Command.dv_show, lambda: DynamicVersioningShowCommand(application) 157 | ) 158 | 159 | try: 160 | local = self._application.poetry.pyproject.data 161 | except RuntimeError: 162 | # We're not in a Poetry project directory 163 | return 164 | 165 | cli.validate(standalone=False, config=local) 166 | 167 | config = _get_config(local) 168 | if not config["enable"]: 169 | return 170 | 171 | application.event_dispatcher.add_listener(COMMAND, self._apply_version) 172 | application.event_dispatcher.add_listener(SIGNAL, self._revert_version) 173 | application.event_dispatcher.add_listener(TERMINATE, self._revert_version) 174 | application.event_dispatcher.add_listener(ERROR, self._revert_version) 175 | 176 | def _apply_version(self, event: ConsoleCommandEvent, kind: str, dispatcher: EventDispatcher) -> None: 177 | if not _should_apply(event.command.name): 178 | return 179 | 180 | io = _should_apply_with_io(event.command.name) 181 | 182 | if hasattr(event.command, "poetry"): 183 | poetry_instance = event.command.poetry 184 | else: 185 | poetry_instance = self._application.poetry 186 | 187 | _apply_version_via_plugin(poetry_instance, io=io) 188 | _patch_dependency_versions(io) 189 | 190 | def _revert_version(self, event: ConsoleCommandEvent, kind: str, dispatcher: EventDispatcher) -> None: 191 | if not _should_apply(event.command.name): 192 | return 193 | 194 | if not _should_apply_with_io(event.command.name): 195 | return 196 | 197 | _revert_version() 198 | -------------------------------------------------------------------------------- /pyproject.plugin.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "poetry-dynamic-versioning-plugin" 3 | version = "0.4.0" 4 | description = "Plugin for Poetry to enable dynamic versioning based on VCS tags" 5 | license = "MIT" 6 | authors = ["Matthew T. Kennerly "] 7 | readme = "README.plugin.md" 8 | repository = "https://github.com/mtkennerly/poetry-dynamic-versioning" 9 | keywords = ["poetry", "plugin", "version", "versioning", "dynamic"] 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Environment :: Plugins", 13 | "Intended Audience :: End Users/Desktop", 14 | "License :: OSI Approved :: MIT License", 15 | "Natural Language :: English", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Topic :: Software Development :: Version Control", 19 | "Topic :: Software Development :: Version Control :: Git", 20 | "Topic :: Software Development :: Version Control :: Mercurial", 21 | "Topic :: Software Development :: Version Control :: Bazaar", 22 | ] 23 | packages = [ 24 | { include = "poetry_dynamic_versioning" } 25 | ] 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.7" 29 | dunamai = "^1.12.0" 30 | tomlkit = ">= 0.4" 31 | jinja2 = ">=2.11.1, <4" 32 | poetry = "^1.2.0" 33 | 34 | [tool.poetry.plugins."poetry.application.plugin"] 35 | poetry-dynamic-versioning-plugin = "poetry_dynamic_versioning.plugin:DynamicVersioningPlugin" 36 | 37 | [build-system] 38 | requires = ["poetry-core>=1.0.0"] 39 | build-backend = "poetry.core.masonry.api" 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "poetry-dynamic-versioning" 3 | version = "1.8.2" 4 | description = "Plugin for Poetry to enable dynamic versioning based on VCS tags" 5 | license = "MIT" 6 | authors = ["Matthew T. Kennerly "] 7 | readme = "README.md" 8 | repository = "https://github.com/mtkennerly/poetry-dynamic-versioning" 9 | keywords = ["poetry", "plugin", "version", "versioning", "dynamic"] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Environment :: Plugins", 13 | "Intended Audience :: End Users/Desktop", 14 | "License :: OSI Approved :: MIT License", 15 | "Natural Language :: English", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Topic :: Software Development :: Version Control", 19 | "Topic :: Software Development :: Version Control :: Git", 20 | "Topic :: Software Development :: Version Control :: Mercurial", 21 | "Topic :: Software Development :: Version Control :: Bazaar", 22 | ] 23 | include = [ 24 | { path = "CHANGELOG.md", format = "sdist" }, 25 | { path = "tests", format = "sdist" }, 26 | ] 27 | 28 | [tool.poetry.dependencies] 29 | python = "^3.7" 30 | dunamai = "^1.23.0" 31 | tomlkit = ">= 0.4" 32 | jinja2 = ">=2.11.1, <4" 33 | poetry = { version = ">=1.2.0", optional = true } 34 | 35 | [tool.poetry.extras] 36 | plugin = ["poetry"] 37 | 38 | [tool.poetry.dev-dependencies] 39 | pytest = "^7.0" 40 | pre-commit = "^1.16" 41 | black = "^22.8" 42 | mypy = "^0.971" 43 | ruff = {version = "^0.0.272", python = "^3.7"} 44 | argparse-manpage = "^4.6" 45 | 46 | [tool.poetry.scripts] 47 | poetry-dynamic-versioning = 'poetry_dynamic_versioning.__main__:main' 48 | 49 | [tool.poetry.plugins."poetry.application.plugin"] 50 | poetry-dynamic-versioning = "poetry_dynamic_versioning.plugin:DynamicVersioningPlugin" 51 | 52 | [tool.black] 53 | line-length = 120 54 | 55 | [tool.mypy] 56 | allow_redefinition = true 57 | 58 | [tool.ruff] 59 | line-length = 120 60 | extend-select = ["W605", "N"] 61 | 62 | [build-system] 63 | requires = ["poetry-core>=1.0.0"] 64 | build-backend = "poetry.core.masonry.api" 65 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import sys 5 | import datetime as dt 6 | from pathlib import Path 7 | 8 | from invoke import task 9 | 10 | ROOT = Path(__file__).parent 11 | PYPROJECT = ROOT / "pyproject.toml" 12 | 13 | NORMAL_PYPROJECT = ROOT / "pyproject.patch.toml" 14 | DEPRECATED_PYPROJECT = ROOT / "pyproject.plugin.toml" 15 | 16 | 17 | def get_version() -> str: 18 | for line in (ROOT / "pyproject.toml").read_text("utf-8").splitlines(): 19 | if line.startswith("version ="): 20 | return line.replace("version = ", "").strip('"') 21 | 22 | raise RuntimeError("Could not determine version") 23 | 24 | 25 | def replace_pattern_in_file(file: Path, old: str, new: str, count: int = 1): 26 | content = file.read_text("utf-8") 27 | updated = re.sub(old, new, content, count=count) 28 | file.write_text(updated, "utf-8") 29 | 30 | 31 | def confirm(prompt: str): 32 | response = input(f"Confirm by typing '{prompt}': ") 33 | if response.lower() != prompt.lower(): 34 | sys.exit(1) 35 | 36 | 37 | @task 38 | def pdv(ctx): 39 | if NORMAL_PYPROJECT.exists(): 40 | PYPROJECT.rename(DEPRECATED_PYPROJECT) 41 | NORMAL_PYPROJECT.rename(PYPROJECT) 42 | 43 | 44 | @task 45 | def pdvp(ctx): 46 | if DEPRECATED_PYPROJECT.exists(): 47 | PYPROJECT.rename(NORMAL_PYPROJECT) 48 | DEPRECATED_PYPROJECT.rename(PYPROJECT) 49 | 50 | 51 | @task 52 | def build(ctx, clean=True): 53 | with ctx.cd(ROOT): 54 | if clean: 55 | shutil.rmtree("dist", ignore_errors=True) 56 | ctx.run("poetry build") 57 | 58 | 59 | @task 60 | def test(ctx, unit=False, integration=False, pattern=None, pipx=False): 61 | all = not unit and not integration 62 | 63 | # This ensures we use the global Poetry instead of the venv's Poetry: 64 | os.environ.update({"POETRY": shutil.which("poetry")}) 65 | 66 | if pipx: 67 | os.environ.update({"POETRY_DYNAMIC_VERSIONING_TEST_INSTALLATION": "pipx"}) 68 | 69 | if pattern is None: 70 | pattern = "" 71 | else: 72 | pattern = "-k {}".format(pattern) 73 | 74 | with ctx.cd(ROOT): 75 | if unit or all: 76 | ctx.run("poetry run pytest tests/test_unit.py {}".format(pattern)) 77 | if integration or all: 78 | ctx.run("poetry run pytest tests/test_integration.py {}".format(pattern)) 79 | 80 | 81 | @task 82 | def install(ctx, pip=False, pipx=False): 83 | with ctx.cd(ROOT): 84 | uninstall(ctx, pip, pipx) 85 | build(ctx) 86 | wheel = next(ROOT.glob("dist/*.whl")) 87 | if pip: 88 | ctx.run('pip install "{}[plugin]"'.format(wheel)) 89 | elif pipx: 90 | ctx.run('pipx inject poetry "{}[plugin]"'.format(wheel)) 91 | else: 92 | ctx.run('poetry self add "{}[plugin]"'.format(wheel)) 93 | 94 | 95 | @task 96 | def uninstall(ctx, pip=False, pipx=False): 97 | try: 98 | if pip: 99 | ctx.run("pip uninstall -y poetry-dynamic-versioning") 100 | elif pipx: 101 | ctx.run("pipx uninject poetry poetry-dynamic-versioning") 102 | else: 103 | ctx.run("poetry self remove poetry-dynamic-versioning") 104 | except Exception: 105 | pass 106 | 107 | 108 | @task 109 | def docs(ctx): 110 | version = get_version() 111 | manpage = "docs/poetry-dynamic-versioning.1" 112 | 113 | args = [ 114 | "poetry", 115 | "run", 116 | "argparse-manpage", 117 | "--pyfile", 118 | "poetry_dynamic_versioning/cli.py", 119 | "--function", 120 | "get_parser", 121 | "--project-name", 122 | "poetry-dynamic-versioning - Dynamic versioning plugin for Poetry", 123 | "--prog", 124 | "poetry-dynamic-versioning", 125 | "--version", 126 | version, 127 | "--author", 128 | "Matthew T. Kennerly (mtkennerly)", 129 | "--url", 130 | "https://github.com/mtkennerly/poetry-dynamic-versioning", 131 | "--format", 132 | "single-commands-section", 133 | "--output", 134 | manpage, 135 | "--manual-title", 136 | "poetry-dynamic-versioning", 137 | ] 138 | 139 | # Join manually to avoid issues with single quotes on Windows using `shlex.join` 140 | joined = " ".join(arg if " " not in arg else f'"{arg}"' for arg in args) 141 | 142 | ctx.run(joined) 143 | 144 | 145 | @task 146 | def prerelease(ctx, new_version): 147 | date = dt.datetime.now().strftime("%Y-%m-%d") 148 | 149 | replace_pattern_in_file( 150 | ROOT / "pyproject.toml", 151 | 'version = ".+"', 152 | f'version = "{new_version}"', 153 | ) 154 | 155 | replace_pattern_in_file( 156 | ROOT / "CHANGELOG.md", 157 | "## Unreleased", 158 | f"## v{new_version} ({date})", 159 | ) 160 | 161 | build(ctx, clean=True) 162 | docs(ctx) 163 | 164 | 165 | @task 166 | def release(ctx): 167 | version = get_version() 168 | 169 | confirm(f"release {version}") 170 | 171 | ctx.run(f'git commit -m "Release v{version}"') 172 | ctx.run(f'git tag v{version} -m "Release"') 173 | ctx.run("git push") 174 | ctx.run(f"git push origin tag v{version}") 175 | 176 | ctx.run("poetry publish") 177 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/poetry-dynamic-versioning/cb22dfc2dca8cc2dbd93b9c450ce3010c085f268/tests/__init__.py -------------------------------------------------------------------------------- /tests/dependency-classic/dependency_classic/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0" 2 | -------------------------------------------------------------------------------- /tests/dependency-classic/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup(name="dependency-classic", version="0.0.666", py_modules=["dependency_classic"]) 4 | -------------------------------------------------------------------------------- /tests/dependency-dynamic/dependency_dynamic/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0" 2 | -------------------------------------------------------------------------------- /tests/dependency-dynamic/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dependency-dynamic" 3 | version = "0.0.888" 4 | description = "" 5 | authors = [] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.5" 9 | 10 | [tool.poetry-dynamic-versioning] 11 | enable = true 12 | 13 | [build-system] 14 | requires = ["poetry-core>=1.0.0"] 15 | build-backend = "poetry.core.masonry.api" 16 | -------------------------------------------------------------------------------- /tests/dependency-static/dependency_static/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0" 2 | -------------------------------------------------------------------------------- /tests/dependency-static/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dependency-static" 3 | version = "0.0.777" 4 | description = "" 5 | authors = [] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.5" 9 | 10 | [build-system] 11 | requires = ["poetry-core>=1.0.0"] 12 | build-backend = "poetry.core.masonry.api" 13 | -------------------------------------------------------------------------------- /tests/project-pep621/project_pep621/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.0" 2 | -------------------------------------------------------------------------------- /tests/project-pep621/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "project-pep621" 3 | dynamic = [ 4 | "version", # a comment that should be preserved 5 | ] 6 | 7 | [tool.poetry] 8 | # The plugin itself doesn't need this, but Poetry does: 9 | # https://github.com/python-poetry/poetry-core/blob/c80dcc53793316104862d2c3ac888dde3c263b08/tests/test_factory.py#L39-L42 10 | version = "0.0.0" 11 | 12 | [tool.poetry-dynamic-versioning] 13 | enable = true 14 | 15 | [build-system] 16 | requires = ["poetry-core>=1.0.0"] 17 | build-backend = "poetry.core.masonry.api" 18 | -------------------------------------------------------------------------------- /tests/project/docs/version.txt: -------------------------------------------------------------------------------- 1 | <0.0.0> 2 | -------------------------------------------------------------------------------- /tests/project/project/__init__.py: -------------------------------------------------------------------------------- 1 | __version__: str = "0.0.0" 2 | __version__ = "0.0.0" 3 | __version_tuple__ = (0, 0, 0) 4 | -------------------------------------------------------------------------------- /tests/project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "project" 3 | version = "0.0.999" 4 | description = "Some special character like Ø, and Ř in UTF-8 encoding." 5 | authors = [] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.5" 9 | dependency-classic = {path = "../dependency-classic"} 10 | dependency-dynamic = {path = "../dependency-dynamic"} 11 | dependency-static = {path = "../dependency-static"} 12 | 13 | [tool.poetry-dynamic-versioning] 14 | enable = true 15 | vcs = "git" 16 | style = "semver" 17 | tag-dir = "alt/tags" 18 | 19 | [tool.poetry-dynamic-versioning.substitution] 20 | folders = [ 21 | { path = "docs", files = ["*.txt"], patterns = ["(<).*(>)"] } 22 | ] 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shlex 4 | import shutil 5 | import subprocess 6 | import tarfile 7 | from pathlib import Path 8 | from typing import List, Optional, Sequence, Tuple 9 | 10 | import dunamai 11 | import pytest 12 | import tomlkit 13 | 14 | ROOT = Path(__file__).parent.parent 15 | DIST = ROOT / "dist" 16 | DUMMY = ROOT / "tests" / "project" 17 | DUMMY_DIST = DUMMY / "dist" 18 | DUMMY_PYPROJECT = DUMMY / "pyproject.toml" 19 | 20 | DUMMY_PEP621 = ROOT / "tests" / "project-pep621" 21 | DUMMY_PEP621_DIST = DUMMY_PEP621 / "dist" 22 | DUMMY_PEP621_PYPROJECT = DUMMY_PEP621 / "pyproject.toml" 23 | 24 | DUMMY_VERSION = "0.0.999" 25 | DEPENDENCY_DYNAMIC_VERSION = "0.0.888" 26 | 27 | 28 | def run( 29 | command: str, 30 | codes: Sequence[int] = (0,), 31 | where: Optional[Path] = None, 32 | shell: bool = False, 33 | env: Optional[dict] = None, 34 | ) -> Tuple[int, str]: 35 | split = shlex.split(command) 36 | 37 | if split[0] == "poetry": 38 | split[0] = os.environ.get("POETRY", "poetry") 39 | 40 | result = subprocess.run( 41 | split, 42 | stdout=subprocess.PIPE, 43 | stderr=subprocess.STDOUT, 44 | cwd=str(where) if where is not None else None, 45 | shell=shell, 46 | env={**os.environ, **env} if env else None, 47 | ) 48 | output = result.stdout.decode("utf-8", errors="ignore").strip() 49 | if codes and result.returncode not in codes: 50 | raise RuntimeError("The command '{}' returned code {}. Output:\n{}".format(command, result.returncode, output)) 51 | return (result.returncode, output) 52 | 53 | 54 | def delete(path: Path) -> None: 55 | if path.is_dir(): 56 | shutil.rmtree(path) 57 | elif path.is_file(): 58 | path.unlink() 59 | 60 | 61 | def install_plugin(artifact: str) -> None: 62 | pipx = os.environ.get("POETRY_DYNAMIC_VERSIONING_TEST_INSTALLATION") == "pipx" 63 | 64 | if pipx: 65 | run(f'pipx inject poetry "{artifact}"') 66 | else: 67 | run(f'poetry self add "{artifact}"') 68 | 69 | 70 | def uninstall_plugin() -> None: 71 | pipx = os.environ.get("POETRY_DYNAMIC_VERSIONING_TEST_INSTALLATION") == "pipx" 72 | 73 | if pipx: 74 | run("pipx uninject poetry poetry-dynamic-versioning", codes=[0, 1]) 75 | else: 76 | run("poetry self remove poetry-dynamic-versioning", codes=[0, 1]) 77 | 78 | 79 | def get_poetry_version() -> List[int]: 80 | _, msg = run("poetry --version") 81 | result = re.search(r"(\d+(\.\d+)+)", msg.strip()) 82 | if result is not None: 83 | parts = result.group(1).split(".") 84 | return [int(x) for x in parts] 85 | return [] 86 | 87 | 88 | @pytest.fixture(scope="module", autouse=True) 89 | def before_all(): 90 | uninstall_plugin() 91 | delete(DIST) 92 | delete(DUMMY / ".venv") 93 | run("poetry build", where=ROOT) 94 | artifact = next(DIST.glob("*.whl")) 95 | install_plugin(artifact) 96 | 97 | yield 98 | 99 | run(f'git checkout -- "{DUMMY.as_posix()}" "{ROOT.as_posix()}/tests/dependency-*"') 100 | uninstall_plugin() 101 | 102 | 103 | @pytest.fixture(autouse=True) 104 | def before_each(): 105 | for project in [DUMMY, DUMMY_PEP621]: 106 | run(f"git checkout -- {project.as_posix()}") 107 | delete(project / "dist") 108 | delete(project / "poetry.lock") 109 | for file in project.glob("*.whl"): 110 | delete(file) 111 | 112 | 113 | def test_plugin_enabled(): 114 | run("poetry build", where=DUMMY) 115 | artifact = next(DUMMY_DIST.glob("*.whl")) 116 | assert DUMMY_VERSION not in artifact.name 117 | 118 | 119 | def test_plugin_disabled(): 120 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 121 | data = data.replace("enable = true", "enable = false") 122 | DUMMY_PYPROJECT.write_bytes(data.encode("utf-8")) 123 | 124 | run("poetry build", where=DUMMY) 125 | artifact = next(DUMMY_DIST.glob("*.whl")) 126 | assert DUMMY_VERSION in artifact.name 127 | 128 | 129 | def test_plugin_disabled_without_plugin_section(): 130 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 131 | data = data.replace("[tool.poetry-dynamic-versioning]", "[tool.poetry-dynamic-versioning-x]") 132 | DUMMY_PYPROJECT.write_bytes(data.encode("utf-8")) 133 | 134 | run("poetry build", where=DUMMY) 135 | artifact = next(DUMMY_DIST.glob("*.whl")) 136 | assert DUMMY_VERSION in artifact.name 137 | 138 | 139 | def test_plugin_disabled_without_pyproject_file(): 140 | delete(DUMMY_PYPROJECT) 141 | run("poetry --help", where=DUMMY) 142 | 143 | 144 | def test_invalid_config_for_vcs(): 145 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 146 | data = data.replace('vcs = "git"', 'vcs = "invalid"') 147 | DUMMY_PYPROJECT.write_bytes(data.encode("utf-8")) 148 | 149 | run("poetry build", where=DUMMY, codes=[1]) 150 | 151 | 152 | @pytest.mark.skipif( 153 | [1, 7] <= get_poetry_version() < [2], reason="virtualenv: error: unrecognized arguments: --wheel=bundle" 154 | ) 155 | def test_keep_pyproject_modifications(): 156 | package = "cachy" 157 | # Using --optional to avoid actually installing the package 158 | if "USE_PEP621" in os.environ: 159 | run(f"poetry add --optional main {package}", where=DUMMY) 160 | else: 161 | run(f"poetry add --optional {package}", where=DUMMY) 162 | # Make sure pyproject.toml contains the new package dependency 163 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 164 | assert package in data 165 | 166 | 167 | def test_poetry_run(): 168 | # The original version is restored before the command runs: 169 | run(f"poetry run grep 'version = \"{DUMMY_VERSION}\"' pyproject.toml", where=DUMMY) 170 | # Make sure original version number is still in place: 171 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 172 | assert f'version = "{DUMMY_VERSION}"' in data 173 | 174 | 175 | @pytest.mark.skipif("CI" in os.environ, reason="Avoid error: 'Inappropriate ioctl for device'") 176 | def test_poetry_shell(): 177 | # Make sure original version number is still in place afterwards: 178 | run("poetry shell", where=DUMMY) 179 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 180 | assert f'version = "{DUMMY_VERSION}"' in data 181 | 182 | 183 | def test_plugin_cli_mode_and_substitution(): 184 | run("poetry dynamic-versioning", where=DUMMY) 185 | # Changes persist after the command is done: 186 | assert f'version = "{DUMMY_VERSION}"' not in DUMMY_PYPROJECT.read_bytes().decode("utf-8") 187 | assert '__version__: str = "0.0.0"' not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 188 | assert '__version__ = "0.0.0"' not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 189 | assert "__version_tuple__ = (0, 0, 0)" not in (DUMMY / "project" / "__init__.py").read_text("utf8") 190 | assert "<0.0.0>" not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 191 | 192 | 193 | def test_standalone_cli_mode_and_substitution(): 194 | run("poetry-dynamic-versioning", where=DUMMY) 195 | # Changes persist after the command is done: 196 | assert f'version = "{DUMMY_VERSION}"' not in DUMMY_PYPROJECT.read_bytes().decode("utf-8") 197 | assert '__version__: str = "0.0.0"' not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 198 | assert '__version__ = "0.0.0"' not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 199 | assert "__version_tuple__ = (0, 0, 0)" not in (DUMMY / "project" / "__init__.py").read_text("utf8") 200 | assert "<0.0.0>" not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 201 | 202 | 203 | def test_cli_mode_and_substitution_without_enable(): 204 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 205 | data = data.replace("enable = true", "enable = false") 206 | DUMMY_PYPROJECT.write_bytes(data.encode("utf-8")) 207 | 208 | run("poetry dynamic-versioning", where=DUMMY) 209 | # Changes persist after the command is done: 210 | assert f'version = "{DUMMY_VERSION}"' not in DUMMY_PYPROJECT.read_bytes().decode("utf-8") 211 | assert '__version__: str = "0.0.0"' not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 212 | assert '__version__ = "0.0.0"' not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 213 | assert "__version_tuple__ = (0, 0, 0)" not in (DUMMY / "project" / "__init__.py").read_text("utf8") 214 | assert "<0.0.0>" not in (DUMMY / "project" / "__init__.py").read_bytes().decode("utf-8") 215 | 216 | 217 | def test_cli_mode_plus_build_will_disable_plugin(): 218 | run("poetry dynamic-versioning", where=DUMMY) 219 | run("poetry build", where=DUMMY) 220 | artifact = next(DUMMY_DIST.glob("*.tar.gz")) 221 | with tarfile.open(artifact, "r:gz") as f: 222 | item = "{}/pyproject.toml".format(artifact.name.replace(".tar.gz", "")) 223 | content = f.extractfile(item).read() 224 | parsed = tomlkit.parse(content) 225 | assert parsed["tool"]["poetry-dynamic-versioning"]["enable"] is False 226 | 227 | 228 | @pytest.mark.skipif( 229 | [1, 7] <= get_poetry_version() < [2], reason="virtualenv: error: unrecognized arguments: --wheel=bundle" 230 | ) 231 | def test_dependency_versions(): 232 | run("poetry install", where=DUMMY) 233 | _, out = run("poetry run pip list --format freeze", where=DUMMY) 234 | assert "dependency-dynamic==" in out 235 | assert f"dependency-dynamic=={DEPENDENCY_DYNAMIC_VERSION}" not in out 236 | assert "dependency-static==0.0.777" in out 237 | assert "dependency-classic==0.0.666" in out 238 | 239 | 240 | def test_poetry_core_as_build_system(): 241 | project = ROOT / "tests" / "dependency-dynamic" 242 | dist = project / "dist" 243 | pyproject = project / "pyproject.toml" 244 | 245 | data = pyproject.read_bytes().decode("utf-8") 246 | data = re.sub( 247 | r"requires = .*", 248 | 'requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]', 249 | data, 250 | ) 251 | data = re.sub( 252 | r"build-backend = .*", 253 | 'build-backend = "poetry_dynamic_versioning.backend"', 254 | data, 255 | ) 256 | pyproject.write_bytes(data.encode("utf-8")) 257 | 258 | run("pip wheel . --no-build-isolation --wheel-dir dist", where=project) 259 | artifact = next(dist.glob("*.whl")) 260 | assert DEPENDENCY_DYNAMIC_VERSION not in artifact.name 261 | 262 | 263 | def test_bumping_enabled(): 264 | data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") 265 | data = data.replace('vcs = "git"', "bump = true") 266 | data = data.replace('style = "semver"', 'style = "pep440"') 267 | DUMMY_PYPROJECT.write_bytes(data.encode("utf-8")) 268 | 269 | run("poetry build", where=DUMMY) 270 | artifact = next(DUMMY_DIST.glob("*.whl")) 271 | assert DUMMY_VERSION not in artifact.name 272 | assert ".post" not in artifact.name 273 | 274 | 275 | def test_bypass(): 276 | run("poetry build", where=DUMMY, env={"POETRY_DYNAMIC_VERSIONING_BYPASS": "1.2.3"}) 277 | artifact = next(DUMMY_DIST.glob("*.whl")) 278 | assert "-1.2.3-" in artifact.name 279 | 280 | 281 | @pytest.mark.skipif("CI" in os.environ, reason="CI uses Pipx, which doesn't play nice with this 'poetry self'") 282 | def test_plugin_show(): 283 | _, out = run("poetry self show") 284 | assert "poetry-dynamic-versioning" in out 285 | 286 | 287 | @pytest.mark.skipif("USE_PEP621" not in os.environ, reason="Requires Poetry with PEP-621 support") 288 | def test_pep621_with_dynamic_version(): 289 | version = dunamai.Version.from_git().serialize() 290 | 291 | run("poetry-dynamic-versioning", where=DUMMY_PEP621) 292 | pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) 293 | assert pyproject["project"]["version"] == version 294 | assert "version" not in pyproject["project"]["dynamic"] 295 | assert f'__version__ = "{version}"' in (DUMMY_PEP621 / "project_pep621" / "__init__.py").read_bytes().decode( 296 | "utf-8" 297 | ) 298 | 299 | 300 | @pytest.mark.skipif("USE_PEP621" not in os.environ, reason="Requires Poetry with PEP-621 support") 301 | def test_pep621_with_dynamic_version_and_cleanup(): 302 | version = dunamai.Version.from_git().serialize() 303 | 304 | contents_before = DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8") 305 | 306 | run("poetry build", where=DUMMY_PEP621) 307 | contents_after = DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8") 308 | assert contents_before == contents_after 309 | 310 | pyproject = tomlkit.parse(contents_after) 311 | assert "version" not in pyproject["project"] 312 | assert "version" in pyproject["project"]["dynamic"] 313 | assert '__version__ = "0.0.0"' in (DUMMY_PEP621 / "project_pep621" / "__init__.py").read_bytes().decode("utf-8") 314 | 315 | artifact = next(DUMMY_PEP621_DIST.glob("*.whl")) 316 | assert f"-{version}-" in artifact.name 317 | 318 | 319 | @pytest.mark.skipif("USE_PEP621" not in os.environ, reason="Requires Poetry with PEP-621 support") 320 | def test_pep621_without_dynamic_version(): 321 | pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) 322 | pyproject["project"]["dynamic"] = [] 323 | DUMMY_PEP621_PYPROJECT.write_bytes(tomlkit.dumps(pyproject).encode("utf-8")) 324 | 325 | run("poetry-dynamic-versioning", codes=[1], where=DUMMY_PEP621) 326 | pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) 327 | assert "version" not in pyproject["project"] 328 | assert '__version__ = "0.0.0"' in (DUMMY_PEP621 / "project_pep621" / "__init__.py").read_bytes().decode("utf-8") 329 | 330 | 331 | def test__command_interop(): 332 | # Just make sure these don't fail with the plugin installed. 333 | folders = [ROOT, ROOT / "tests" / "dependency-dynamic"] 334 | 335 | for folder in folders: 336 | run("poetry list", where=folder) 337 | -------------------------------------------------------------------------------- /tests/test_unit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | from pathlib import Path 4 | 5 | import pytest 6 | import tomlkit 7 | from dunamai import Version 8 | 9 | import poetry_dynamic_versioning as plugin 10 | from poetry_dynamic_versioning import cli 11 | 12 | root = Path(__file__).parents[1] 13 | 14 | 15 | @pytest.fixture 16 | def config(): 17 | return plugin._default_config()["tool"]["poetry-dynamic-versioning"] 18 | 19 | 20 | def test__deep_merge_dicts(): 21 | assert plugin._deep_merge_dicts({}, {}) == {} 22 | assert plugin._deep_merge_dicts({"a": 1}, {"a": 2}) == {"a": 2} 23 | assert plugin._deep_merge_dicts({"a": {"b": 2}}, {"a": 1}) == {"a": 1} 24 | assert plugin._deep_merge_dicts({"a": {"b": 2}}, {"a": {"c": 3}}) == {"a": {"b": 2, "c": 3}} 25 | 26 | 27 | def test__find_higher_file(): 28 | assert plugin._find_higher_file("pyproject.toml", start=root) == root / "pyproject.toml" 29 | assert plugin._find_higher_file("pyproject.toml", start=root / "tests") == root / "pyproject.toml" 30 | assert ( 31 | plugin._find_higher_file("pyproject.toml", start=root / "tests" / "project") 32 | == root / "tests" / "project" / "pyproject.toml" 33 | ) 34 | 35 | 36 | def test__get_config_from_path__without_plugin_customizations(): 37 | config = plugin._get_config_from_path(root) 38 | assert config["vcs"] == "any" 39 | assert config["style"] is None 40 | assert config["tag-dir"] == "tags" 41 | 42 | 43 | def test__get_config_from_path__with_plugin_customizations(): 44 | config = plugin._get_config_from_path(root / "tests" / "project") 45 | assert config["vcs"] == "git" 46 | assert config["style"] == "semver" 47 | assert config["tag-dir"] == "alt/tags" 48 | 49 | 50 | def test__get_config__bump(): 51 | config = plugin._get_config({"tool": {"poetry-dynamic-versioning": {"bump": True}}}) 52 | bump = plugin._BumpConfig.from_config(config["bump"]) 53 | assert bump.enable is True 54 | assert bump.index == -1 55 | assert not plugin._validate_config(config) 56 | 57 | config = plugin._get_config({"tool": {"poetry-dynamic-versioning": {"bump": {"enable": True, "index": -2}}}}) 58 | bump = plugin._BumpConfig.from_config(config["bump"]) 59 | assert bump.enable is True 60 | assert bump.index == -2 61 | assert not plugin._validate_config(config) 62 | 63 | 64 | def test__get_version__defaults(config): 65 | assert plugin._get_version(config)[0] == Version.from_git().serialize() 66 | 67 | 68 | def test__get_version__invalid_vcs(config): 69 | config["vcs"] = "invalid" 70 | with pytest.raises(ValueError): 71 | plugin._get_version(config) 72 | 73 | 74 | def test__get_version__invalid_style(config): 75 | config["style"] = "invalid" 76 | with pytest.raises(ValueError): 77 | plugin._get_version(config) 78 | 79 | 80 | def test__get_version__format_jinja(config): 81 | os.environ["FOO"] = "foo" 82 | config["format-jinja"] = "{% if true %}v1+{{ env['FOO'] }}{% endif %}" 83 | assert plugin._get_version(config)[0] == "v1+foo" 84 | 85 | 86 | def test__get_version__format_jinja_with_enforced_style(config): 87 | config["format-jinja"] = "{% if true %}1+jinja{% endif %}" 88 | config["style"] = "pvp" 89 | with pytest.raises(ValueError): 90 | plugin._get_version(config) 91 | 92 | 93 | def test__get_version__format_jinja_imports_with_module_only(config): 94 | config["format-jinja"] = "{{ math.pow(2, 2) }}" 95 | config["format-jinja-imports"] = [{"module": "math", "item": None}] 96 | assert plugin._get_version(config)[0] == "4.0" 97 | 98 | 99 | def test__get_version__format_jinja_imports_with_module_and_item(config): 100 | config["format-jinja"] = "{{ pow(2, 3) }}" 101 | config["format-jinja-imports"] = [{"module": "math", "item": "pow"}] 102 | assert plugin._get_version(config)[0] == "8.0" 103 | 104 | 105 | def test__get_override_version__bypass(): 106 | env = {plugin._BYPASS_ENV: "0.1.0"} 107 | assert plugin._get_override_version(None, env) == "0.1.0" 108 | assert plugin._get_override_version("foo", env) == "0.1.0" 109 | 110 | 111 | def test__get_override_version__override(): 112 | env = {plugin._OVERRIDE_ENV: "foo=0.1.0,bar=0.2.0"} 113 | assert plugin._get_override_version(None, env) is None 114 | assert plugin._get_override_version("foo", env) == "0.1.0" 115 | assert plugin._get_override_version("bar", env) == "0.2.0" 116 | assert plugin._get_override_version("baz", env) is None 117 | 118 | 119 | def test__get_override_version__combined(): 120 | env = {plugin._BYPASS_ENV: "0.0.0", plugin._OVERRIDE_ENV: "foo = 0.1.0, bar = 0.2.0"} 121 | assert plugin._get_override_version(None, env) == "0.0.0" 122 | assert plugin._get_override_version("foo", env) == "0.1.0" 123 | 124 | 125 | def test__enable_in_doc__empty(): 126 | doc = tomlkit.parse("") 127 | updated = cli._enable_in_doc(doc) 128 | assert ( 129 | tomlkit.dumps(updated) 130 | == textwrap.dedent( 131 | """ 132 | [tool.poetry-dynamic-versioning] 133 | enable = true 134 | 135 | [build-system] 136 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 137 | build-backend = "poetry_dynamic_versioning.backend" 138 | """ 139 | ).lstrip() 140 | ) 141 | 142 | 143 | def test__enable_in_doc__added_pdv(): 144 | doc = tomlkit.parse( 145 | textwrap.dedent( 146 | """ 147 | [tool] 148 | foo = 1 149 | """ 150 | ) 151 | ) 152 | updated = cli._enable_in_doc(doc) 153 | assert tomlkit.dumps(updated) == textwrap.dedent( 154 | """ 155 | [tool] 156 | foo = 1 157 | 158 | [tool.poetry-dynamic-versioning] 159 | enable = true 160 | 161 | [build-system] 162 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 163 | build-backend = "poetry_dynamic_versioning.backend" 164 | """ 165 | ) 166 | 167 | 168 | def test__enable_in_doc__updated_enable(): 169 | doc = tomlkit.parse( 170 | textwrap.dedent( 171 | """ 172 | [tool.poetry-dynamic-versioning] 173 | enable = false 174 | """ 175 | ) 176 | ) 177 | updated = cli._enable_in_doc(doc) 178 | assert tomlkit.dumps(updated) == textwrap.dedent( 179 | """ 180 | [tool.poetry-dynamic-versioning] 181 | enable = true 182 | 183 | [build-system] 184 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 185 | build-backend = "poetry_dynamic_versioning.backend" 186 | """ 187 | ) 188 | 189 | 190 | def test__enable_in_doc__updated_requires(): 191 | doc = tomlkit.parse( 192 | textwrap.dedent( 193 | """ 194 | [build-system] 195 | requires = ["foo"] 196 | """ 197 | ) 198 | ) 199 | updated = cli._enable_in_doc(doc) 200 | assert tomlkit.dumps(updated) == textwrap.dedent( 201 | """ 202 | [build-system] 203 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 204 | build-backend = "poetry_dynamic_versioning.backend" 205 | 206 | [tool.poetry-dynamic-versioning] 207 | enable = true 208 | """ 209 | ) 210 | 211 | 212 | def test__enable_in_doc__updated_build_backend(): 213 | doc = tomlkit.parse( 214 | textwrap.dedent( 215 | """ 216 | [build-system] 217 | build-backend = "" 218 | """ 219 | ) 220 | ) 221 | updated = cli._enable_in_doc(doc) 222 | assert tomlkit.dumps(updated) == textwrap.dedent( 223 | """ 224 | [build-system] 225 | build-backend = "poetry_dynamic_versioning.backend" 226 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 227 | 228 | [tool.poetry-dynamic-versioning] 229 | enable = true 230 | """ 231 | ) 232 | 233 | 234 | def test__enable_in_doc__out_of_order_tables(): 235 | doc = tomlkit.parse( 236 | textwrap.dedent( 237 | """ 238 | [tool.poetry] 239 | name = "foo" 240 | 241 | [build-system] 242 | build-backend = "" 243 | 244 | [tool.poetry.dependencies] 245 | python = "^3.10" 246 | """ 247 | ) 248 | ) 249 | updated = cli._enable_in_doc(doc) 250 | assert tomlkit.dumps(updated) == textwrap.dedent( 251 | """ 252 | [tool.poetry] 253 | name = "foo" 254 | 255 | [tool.poetry-dynamic-versioning] 256 | enable = true 257 | [build-system] 258 | build-backend = "poetry_dynamic_versioning.backend" 259 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 260 | 261 | [tool.poetry.dependencies] 262 | python = "^3.10" 263 | """ 264 | ) 265 | 266 | 267 | def test__enable_in_doc__project_with_version(): 268 | doc = tomlkit.parse( 269 | textwrap.dedent( 270 | """ 271 | [project] 272 | version = "1.2.3" 273 | """ 274 | ) 275 | ) 276 | updated = cli._enable_in_doc(doc) 277 | assert tomlkit.dumps(updated) == textwrap.dedent( 278 | """ 279 | [project] 280 | dynamic = ["version"] 281 | 282 | [tool] 283 | [tool.poetry-dynamic-versioning] 284 | enable = true 285 | 286 | [tool.poetry] 287 | version = "0.0.0" 288 | 289 | [build-system] 290 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 291 | build-backend = "poetry_dynamic_versioning.backend" 292 | """ 293 | ) 294 | 295 | 296 | def test__enable_in_doc__project_with_version_override(): 297 | doc = tomlkit.parse( 298 | textwrap.dedent( 299 | """ 300 | [project] 301 | """ 302 | ) 303 | ) 304 | updated = cli._enable_in_doc(doc, env={plugin._BYPASS_ENV: "1.2.3"}) 305 | assert tomlkit.dumps(updated) == textwrap.dedent( 306 | """ 307 | [project] 308 | dynamic = ["version"] 309 | 310 | [tool] 311 | [tool.poetry-dynamic-versioning] 312 | enable = true 313 | 314 | [tool.poetry] 315 | version = "1.2.3" 316 | 317 | [build-system] 318 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 319 | build-backend = "poetry_dynamic_versioning.backend" 320 | """ 321 | ) 322 | 323 | 324 | def test__enable_in_doc__project_with_dynamic(): 325 | doc = tomlkit.parse( 326 | textwrap.dedent( 327 | """ 328 | [project] 329 | dynamic = ["version", "dependencies"] 330 | """ 331 | ) 332 | ) 333 | updated = cli._enable_in_doc(doc) 334 | assert tomlkit.dumps(updated) == textwrap.dedent( 335 | """ 336 | [project] 337 | dynamic = ["version", "dependencies"] 338 | 339 | [tool] 340 | [tool.poetry-dynamic-versioning] 341 | enable = true 342 | 343 | [tool.poetry] 344 | version = "0.0.0" 345 | 346 | [build-system] 347 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 348 | build-backend = "poetry_dynamic_versioning.backend" 349 | """ 350 | ) 351 | 352 | 353 | def test__substitute_version_in_text__integers_only(): 354 | content = textwrap.dedent( 355 | """ 356 | __version__: str = "0.0.0" 357 | __version__ = "0.0.0" 358 | __version_tuple__ = (0, 0, 0) 359 | """ 360 | ) 361 | output = textwrap.dedent( 362 | """ 363 | __version__: str = "0.1.2" 364 | __version__ = "0.1.2" 365 | __version_tuple__ = (0, 1, 2) 366 | """ 367 | ) 368 | version = "0.1.2" 369 | patterns = plugin._SubPattern.from_config( 370 | plugin._default_config()["tool"]["poetry-dynamic-versioning"]["substitution"]["patterns"] 371 | ) 372 | assert plugin._substitute_version_in_text(version, content, patterns) == output 373 | 374 | 375 | def test__substitute_version_in_text__mixed(): 376 | content = textwrap.dedent( 377 | """ 378 | __version__: str = "0.0.0" 379 | __version__ = "0.0.0" 380 | __version_tuple__ = (0, 0, 0) 381 | """ 382 | ) 383 | output = textwrap.dedent( 384 | """ 385 | __version__: str = "0.1.2.dev0-post.4+meta.data" 386 | __version__ = "0.1.2.dev0-post.4+meta.data" 387 | __version_tuple__ = (0, 1, 2, "dev0", "post", 4, "meta.data") 388 | """ 389 | ) 390 | version = "0.1.2.dev0-post.4+meta.data" 391 | patterns = plugin._SubPattern.from_config( 392 | plugin._default_config()["tool"]["poetry-dynamic-versioning"]["substitution"]["patterns"] 393 | ) 394 | assert plugin._substitute_version_in_text(version, content, patterns) == output 395 | --------------------------------------------------------------------------------