Manifest Configuration¶
Polytropos generates the __manifest__.py dynamically for each release based on your
pyproject.toml configuration.
Version vs. Release¶
For Polytropos we use this specific terminology:
- Release refers to Odoo release (Odoo 18.0, 19.0, etc.)
- Version refers to your addon version (1.0.0, 2.34.1, etc.).
Therefore, a single version of the addon can have different releases. Built artifacts get a combination of release and version.
Example
# Build 2 releases for the conditional_assets example
cd examples/conditional_assets
uv build --wheel --config-setting odoo_release=19.0
uv build --wheel --config-setting odoo_release=18.0
# Check all releases artifacts
ls -1 dist/
conditional_assets-18.0.1.0.0-py3-none-any.whl
conditional_assets-18.0.1.0.0.tar.gz
conditional_assets-19.0.1.0.0-py3-none-any.whl
conditional_assets-19.0.1.0.0.tar.gz
Dynamic Version¶
Your pyproject.toml must declare version as dynamic:
This is required because Polytropos generates a version that combines the Odoo release
with your module version (e.g., 18.0.1.2.3). Without dynamic = ["version"], tools
like uv cannot properly resolve dependencies that depend on the Odoo release.
For example, if your addon depends on odoo-addon-other==18.0.*, but your addon is
tagged with version 1.2.3, uv will fail to solve dependencies because it sees a
mismatch between the static version in [project] and the actual wheel version.
Polytropos emits a warning during build if version is not declared as dynamic.
Manifest values auto-populated from [project]¶
Several manifest fields are automatically populated from
the standards-based [project] section
of pyproject.toml:
| Manifest Field | pyproject.toml Source |
|---|---|
name |
project.name |
summary |
project.description |
description |
project.readme (see README Handling) |
author |
project.authors[*].name |
maintainers |
project.maintainers[*].{name,email} (see Maintainers) |
license |
project.license |
website |
project.urls.Homepage |
depends |
project.dependencies (see Dependencies) |
external_dependencies.python |
project.dependencies (see Dependencies) |
This avoids duplication - you define these once in [project] and they automatically
appear in the manifest.
The name field is generated from the module name (converted to title case, e.g.,
my_module → My Module). You can override it explicitly in
[[tool.polytropos.manifest]].
Example
These instructions:
[build-system]
requires = ["polytropos[build]"]
build-backend = "polytropos.build"
[project]
name = "odoo-addon-my-module"
version = "1.22.30"
description = "My awesome Odoo module"
authors = [
{name = "My Name", email = "my-email@example.com"},
{name = "Other Name", email = "other-email@example.com"},
]
license = "LGPL-3"
[project.urls]
Homepage = "https://mywebsite.com"
[tool.polytropos]
default_odoo_release = "19.0"
Produce this manifest:
Polytropos will never add any other fields to the manifest automatically.
Maintainers¶
The maintainers field in __manifest__.py is not used by Odoo upstream. However, it
is used in OCA, where it expects a list of GitHub usernames. When you specify
project.maintainers in pyproject.toml, Polytropos extracts the username
automatically:
- GitHub noreply email → Extracts the username from the email pattern.
- Regular email only -> Uses the
emailfield as-is. - Name only, or regular email + name → Uses the
namefield as-is
Example
[project]
maintainers = [
{name = "John Doe", email = "12345+johndoe@users.noreply.github.com"},
{name = "Alice Johnson", email = "alice@users.noreply.github.com"},
{name = "Jane Smith", email = "jane@example.com"},
{name = "Bob Wilson"},
{email = "pocahontas@example.com"},
]
Produces:
README Handling¶
If project.readme is specified in pyproject.toml, Polytropos automatically converts
it into reStructuredText syntax and
sets it as the description field in the manifest.
The format is auto-detected from the file extension (it follows the PyPA specification):
README.md→ text/markdownREADME.rst→ text/x-rst- Other extensions → text/plain
Example
[build-system]
requires = ["polytropos[build]"]
build-backend = "polytropos.build"
[project]
name = "odoo-addon-my-module"
version = "1.0.0"
dynamic = ["version"]
readme = "README.md"
[tool.polytropos]
default_odoo_release = "19.0"
If README.md contains:
The generated manifest will include:
{
"name": "My Module",
"description": """My Module
=========
This module does amazing things.""",
"version": "19.0.1.0.0",
}
You can also use RST directly:
Pandoc Include¶
To perform all README conversion magic, these binaries must be present:
Tip
If you build using Nix, these are already included as
propagatedNativeBuildInputs of Polytropos.
Example
These files:
[build-system]
requires = ["polytropos[build]"]
build-backend = "polytropos.build"
[project]
name = "odoo-addon-my-module"
version = "1.0.0"
dynamic = ["version"]
readme = "README.md"
dependencies = ["odoo"]
[tool.polytropos]
default_odoo_release = "19.0"
Produce this manifest:
Image Paths¶
Image paths in README files are automatically adjusted to let them display properly in Odoo's UI.
Example
With this structure:
```toml title="pyproject.toml"
[build-system]
requires = ["polytropos[build]"]
build-backend = "polytropos.build"
[project]
name = "odoo-addon-my-module"
version = "1.0.0"
dynamic = ["version"]
readme = "README.md"
[tool.polytropos]
default_odoo_release = "19.0"
src = "src" # It works in non-src layout too
```
```markdown title="README.md"
# My Module

```
The generated manifest will contain:
```python title="__manifest__.py"
{
"name": "My Module",
"description": """My Module
=========
|Icon|
.. |Icon| image:: /my_module/static/description/icon.png""",
"version": "19.0.1.0.0",
}
```
Polytropos uses a Lua filter to rewrite image paths during conversion:
- Reads the
tool.polytropos.srcdirectory frompyproject.toml(default:.) - Strips the leading
./from relative paths - Replaces the source directory prefix with
/<module_name>/for Odoo's URL system
This ensures images work correctly when displayed in the Odoo UI, regardless of the original path in your git repository.
Project Name¶
The project name must start with odoo-addon- and use hyphens between words. This makes
your modules compatible with the wider Odoo addon package builders ecosystem.
The conversion follows these rules:
- Package name (Python):
odoo-addon-module-name(hyphens between words) - Module directory:
odoo/addons/module_name(underscores between words) - Manifest name:
Module Name(human-readable title case)
You can also override the module name explicitly. See below.
Overriding Auto-populated Fields¶
If you need to override an auto-populated field, you can do so in
[tool.polytropos.manifest]:
Tip
A warning will be emitted if you define the same field in both [project] and
[tool.polytropos.manifest]. The manifest value takes precedence, but it's cleaner to
define it only in [project].
Version-specific Overrides¶
Use overrides to specify different values per Odoo release using PEP 440 specifiers:
[[tool.polytropos.manifest]]
depends = ["base"]
data = [
"views/res_partner.xml"
]
[[tool.polytropos.manifest]]
releases = ">=18"
application = true
[[tool.polytropos.manifest]]
releases = "<16"
data = [
"views/res_partner_legacy.xml"
]
Merge Behavior¶
When merging, Polytropos follows these rules strictly:
releaseskey is removed (only used by Polytropos)- Dicts are merged: Keys are combined recursively
- Lists are combined: Both sets of items are kept
- Values are replaced: The last defined value wins
All manifest fields support overrides: depends, data, demo, assets,
application, installable, etc.
Assets (CSS/JS)¶
Odoo uses asset bundles for web files. Define them in pyproject.toml:
[[tool.polytropos.manifest]]
depends = ["base"]
assets."my_module.assets_backend" = [
# Include from another bundle
["include", "my_module.assets_common"],
# Remove a file
["remove", "my_module/static/src/css/base.css"],
# Add before a reference
["prepend", "my_module/static/src/css/ref.css", "my_module/static/src/css/new.css"],
# Add after a reference
["after", "my_module/static/src/css/ref.css", "my_module/static/src/css/new.css"],
# Direct file
"my_module/static/src/css/legacy.css",
]
# Override for Odoo 18+
[[tool.polytropos.manifest]]
releases = ">=18"
assets."my_module.assets_backend" = [
["remove", "my_module/static/src/css/legacy.css"],
]
Polytropos is doing nothing special with this, just following its normal rules. Check out Odoo Asset Documentation to know how Odoo will process assets.
Dependencies¶
If Polytropos addons don't define the odoo dependency, it will be implicitly added and
all releases will be buildable.
If odoo is defined, you will not be able to build wheels for incompatible releases.
Other dependencies can be added as [project].dependencies (both addons (with
odoo-addon- prefix) and other dependencies, for all releases), as
[[tool.polytropos.manifest]].depends (only addons (without prefix)) or as
[[tool.polytropos.manifest]].external_dependencies.python (both multi-version).
Example
These instructions:
[project]
name = "odoo-addon-example"
version = "1.0.0"
dependencies = [
# Forbid building for 17.0 or earlier
"odoo>=18.0",
# Depend on this OCA addon for all releases
"odoo-addon-partner-fax",
# Depend on this standard Python dependency on all releases
"funcy>=1",
]
[tool.polytropos]
default_odoo_release = "19.0"
[[tool.polytropos.manifest]]
depends = [
# Odoo CE/EE addons are never packaged on PyPi, so you must specify
# these dependencies always directly here in the manifest section
"sale",
# You can also add OCA or other addon dependencies here if you prefer
"queue_job",
]
external_dependencies.python = [
# You can add Python dependencies here if you prefer
"cowsay>6",
]
[[tool.polytropos.manifest]]
# These overrides are only for Odoo 19+
releases = ">=19"
depends = [
"project", # Odoo CE
"queue_job_cron", # OCA
]
external_dependencies.python = [
"cowsay>7",
"copier>=9.0",
]
Will build this manifest for Odoo 18:
{
"name": "Example",
"version": "18.0.1.0.0",
"depends": [
"partner_fax",
"sale",
"queue_job",
],
"external_dependencies": {
"python": [
"cowsay>6",
],
},
}
And this manifest for Odoo 19:
{
"name": "Example",
"version": "19.0.1.0.0",
"depends": [
"project",
"queue_job_cron",
"sale",
"queue_job",
],
"external_dependencies": {
"python": [
"cowsay>6",
"cowsay>7",
"copier>=9.0",
],
},
}
At the same time, the wheel will depend on these Python packages:
- Odoo 18 release:
cowsay>6funcy>=1odoo-addon-partner-faxodoo-addon-queue-jobodoo==18.0.*
- Odoo 19 release:
copier>=9.0cowsay>7odoo-addon-partner-faxodoo-addon-queue-job-cronodoo-addon-queue-jobodoo==19.0.*
See Also¶
- Examples Folder - Real-world module examples
File Maps¶
Use [[tool.polytropos.files]] to override files in the built module based on the Odoo
release. This is useful when Odoo expects a fixed file path but you need different
content per release (e.g.,
_parse_csv in account.chart.template).
Each entry maps source files into a destination directory within
the addon root, overwriting any existing files. Only
entries matching the target Odoo release (via releases specifier) are applied.
Two modes are available:
-
src_files+dst_dir: Select specific files using one or more glob patterns. You can pass a single string or an array of patterns. Each matched file is placed intodst_dir. If multiple patterns match the same file, it is only copied once. -
src_dir+dst_dir: The entiresrc_diris symlinked asdst_dir. The destination becomes a direct symlink to the source; you cannot use this mode to merge contents.
Warning
Always git-ignore dst_dir. Its contents will differ by release. Keep only
src_files/src_dir in git and generate dst_dir during build.
# For Odoo < 18: copy all CSV files from 17.0/ into data/template/
[[tool.polytropos.files]]
releases = "<18"
src_files = "data/17.0/template/*.csv"
dst_dir = "data/template"
# For Odoo >= 18: symlink entire data/18.0/template/ as data/template/
[[tool.polytropos.files]]
releases = ">=18"
src_dir = "data/18.0/template"
dst_dir = "data/template"
operation = "symlink" # Default; see below
With this directory structure:
📄 .gitignore
📁 src/
📁 data/
📁 17.0/
📁 template/
📄 account.tax-es_common.csv
📄 account.fiscal.position-es_common.csv
📁 18.0/
📁 template/
📄 account.tax-es_common.csv
📄 account.fiscal.position-es_common.csv
📄 __init__.py
The resulting output for Odoo 18 will contain:
data/template/
account.tax-es_common.csv (symlinked to 18.0/template/)
account.fiscal.position-es_common.csv
Build modes¶
- Wheel: Files are copied or, for
operation = "render", written after XML processing. An error is raised if the source directory does not exist. - Editable: For
operation = "symlink", matching symlinks are created (or files copied if the OS does not support symlinks); non-matching symlinks are removed. Foroperation = "render", the rendered XML is always written directly (no symlink, since content differs from source). Rebuilding regenerates the files. An error is raised if the source directory does not exist. Files not in.gitignoretrigger a warning. Existing real files indstare skipped with a warning. - Sdist: File maps are not applied (sdist is version-independent).
Operation¶
Each file map entry can specify an operation key. The default is "symlink", which
preserves the current behavior for all build modes. The alternative is "render".
Symlink operation¶
When operation = "symlink" (the default), files are linked into the destination
directory. The behavior depends on the build mode as described in
Build modes.
Render operation¶
When operation = "render", the source files are expected to be XML. During build,
Polytropos processes the XML content, removing elements and attributes that do not apply
to the target Odoo release. The rendered XML is written to the destination.
This allows you to maintain a single XML source file with version-conditional content, avoiding the need for separate files per release.
XML namespace syntax¶
Use a custom XML namespace to mark elements and attributes with a release condition. The namespace URI encodes a PEP 440 version specifier:
Because < is not allowed literally in XML attribute values, use &lt; when the
specifier includes it:
<odoo
xmlns:le18="https://polytropos.moduon.team/v1/rel/<=18.0"
xmlns:ge19="https://polytropos.moduon.team/v1/rel/>=19.0"
>
The prefix (le18, ge19, etc.) is arbitrary. Only the namespace URI matters.
Elements: When an element tag is namespace-qualified, the entire element is kept (with the namespace stripped) if the condition matches, or removed if it does not:
<!-- Only included when building for Odoo ≤18.0 -->
<le18:record model="res.partner" id="partner_legacy">
<field name="name">Legacy Partner</field>
</le18:record>
<!-- Only included when building for Odoo ≥19.0 -->
<ge19:record model="res.partner" id="partner_new">
<field name="name">New Partner</field>
</ge19:record>
Attributes: When an attribute key is namespace-qualified, only that attribute is removed or renamed. This is useful for release-specific values on otherwise identical elements. A common pattern is to set a default value as a regular attribute and override it with a namespace-qualified one for specific releases:
<!-- Override description for Odoo ≤18.0 -->
<field name="description"
le18:value="Old description"
value="Default description"
/>
<!-- readonly attribute only in Odoo ≥19.0 -->
<field name="email" ge19:readonly="1"/>
For Odoo ≤18.0, description is "Old description". For later releases, the
le18:value attribute is removed and description keeps its default
"Default description".
Element removal¶
When an element is removed due to a non-matching condition, all its children and attributes are removed as well, regardless of their own conditions.
<!-- For Odoo 17.0: the entire v18_section is removed -->
<le18:record model="ir.ui.view" id="v18_section">
<!-- This child would never be evaluated -->
<ge19:field name="imaginary" />
</le18:record>
Migration example¶
A realistic use case is the res.groups model change between Odoo 18 and 19. In v18,
groups had a category_id field and a users field. In v19, category_id was replaced
by privilege_id (pointing to the new res.groups.privilege model), and users was
renamed to user_ids. You can handle both releases from a single data file:
<odoo
xmlns:le18="https://polytropos.moduon.team/v1/rel/<=18.0"
xmlns:ge19="https://polytropos.moduon.team/v1/rel/>=19.0"
>
<!-- Entire record only relevant for Odoo 19 -->
<ge19:record id="privilege_books" model="res.groups.privilege">
<field name="name">Books</field>
</ge19:record>
<record id="group_book_reader" model="res.groups">
<field name="name">Book Reader</field>
<!-- category_id exists only in Odoo ≤18.0 -->
<le18:field name="category_id" ref="base.module_category_hidden" />
<!-- privilege_id exists only in Odoo ≥19.0 -->
<ge19:field name="privilege_id" ref="privilege_books" />
<!-- users renamed to user_ids in Odoo ≥19.0 -->
<field
le18:name="users"
name="user_ids"
eval="[Command.link(ref('base.user_admin'))]"
/>
</record>
</odoo>
Building for 18.0 produces:
<odoo>
<record id="group_book_reader" model="res.groups">
<field name="name">Book Reader</field>
<field name="category_id" ref="base.module_category_hidden" />
<field name="users" eval="[Command.link(ref('base.user_admin'))]" />
</record>
</odoo>
Building for 19.0 produces:
<odoo>
<record id="privilege_books" model="res.groups.privilege">
<field name="name">Books</field>
</record>
<record id="group_book_reader" model="res.groups">
<field name="name">Book Reader</field>
<field name="privilege_id" ref="privilege_books" />
<field name="user_ids" eval="[Command.link(ref('base.user_admin'))]" />
</record>
</odoo>
Configuration example¶
[[tool.polytropos.files]]
operation = "render"
src_files = "views/*.xml"
dst_dir = "rendered/views"
Warning
The dst_dir must be git-ignored when using operation = "render". In
editable builds, rendered files are written directly into dst_dir within the
source tree. Their content changes with the target Odoo release, so committing
them would cause conflicts when different developers build for different releases
or when rebuilding for a different release.
[[tool.polytropos.files]] must be a list (double bracket syntax).