├── .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 |
--------------------------------------------------------------------------------