odoo-dev 1.2.0__tar.gz → 1.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odoo_dev-1.2.2/.release-please-manifest.json +3 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/CHANGELOG.md +20 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/PKG-INFO +16 -4
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/README.md +14 -2
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/pyproject.toml +1 -1
- odoo_dev-1.2.2/src/odoo_dev/__init__.py +3 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/run.py +37 -8
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/vendor.py +41 -3
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/develop.py +15 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/edit.py +9 -2
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/materialize.py +15 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/migrate.py +127 -17
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/update.py +26 -7
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/verify.py +49 -1
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_develop.py +37 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_edit.py +33 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_materialize.py +31 -0
- odoo_dev-1.2.2/tests/test_vendor_migrate.py +256 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_update.py +25 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_verify.py +67 -2
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/uv.lock +1 -1
- odoo_dev-1.2.0/.release-please-manifest.json +0 -3
- odoo_dev-1.2.0/src/odoo_dev/__init__.py +0 -3
- odoo_dev-1.2.0/tests/test_vendor_migrate.py +0 -128
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.github/workflows/release-please.yml +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.gitignore +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.gitmodules +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.python-version +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/release-please-config.json +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/cli.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/bump.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/db.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/docker.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/setup.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/config.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/preflight.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/docker/Dockerfile +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/docker/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/launch.json +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/settings.json +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/tasks.json +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/utils/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/utils/console.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/utils/manifest.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/lock.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/sources.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/sync.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/fixtures/__init__.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_bump.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_cli.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_config.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_db.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_db_config.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_drop_database.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_integration.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_preflight.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_run.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_setup.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_setup_db.py +0 -0
- {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_lock.py +0 -0
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.2](https://github.com/bemade/odoo-dev/compare/v1.2.1...v1.2.2) (2026-08-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **vendor:** exclude python bytecode from the vendored tree comparison ([eab5a0a](https://github.com/bemade/odoo-dev/commit/eab5a0a0779821432724e2be31dcb416e5a60c6b)), closes [#11](https://github.com/bemade/odoo-dev/issues/11)
|
|
9
|
+
* **vendor:** fail when .gitignore would strip files out of vendored/ ([6880616](https://github.com/bemade/odoo-dev/commit/6880616fb3f8d503171f7a6e0f5f6bfe98b667a8)), closes [#13](https://github.com/bemade/odoo-dev/issues/13)
|
|
10
|
+
* **vendor:** pin from the recorded gitlink, refuse uninitialized submodules ([001f86f](https://github.com/bemade/odoo-dev/commit/001f86ff1ae4b89b89681aa8e9878cf2bd9f1f1c)), closes [#12](https://github.com/bemade/odoo-dev/issues/12)
|
|
11
|
+
* **vendor:** three failure modes found in vendoring + CI cycles ([051715a](https://github.com/bemade/odoo-dev/commit/051715a10a17163ebe79cd2fe05a7bbe42328511))
|
|
12
|
+
|
|
13
|
+
## [1.2.1](https://github.com/bemade/odoo-dev/compare/v1.2.0...v1.2.1) (2026-07-14)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **test:** make full-suite runs work under a dev odoo.conf ([2a1b752](https://github.com/bemade/odoo-dev/commit/2a1b7527d29b3bf8bb776e41ce739b4864543b73))
|
|
19
|
+
* **vendor:** bump clears stale version when pinning to a commit ([19544ba](https://github.com/bemade/odoo-dev/commit/19544bac55a3dded4b912cc9f2ac7226f5d72d61))
|
|
20
|
+
* **vendor:** constrain `vendor update` to the pin's own major series ([5b58e26](https://github.com/bemade/odoo-dev/commit/5b58e2676de6c1b63a678d1dd365efca9e4c8b99))
|
|
21
|
+
* **vendor:** migrate sets version only when the tag resolves to the pin ([ea59b1d](https://github.com/bemade/odoo-dev/commit/ea59b1d62c9844fb831925d9973b4ec179b803e6))
|
|
22
|
+
|
|
3
23
|
## [1.2.0](https://github.com/bemade/odoo-dev/compare/v1.1.0...v1.2.0) (2026-07-07)
|
|
4
24
|
|
|
5
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: odoo-dev
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: Odoo Development Environment Helper
|
|
5
5
|
Project-URL: Homepage, https://git.bemade.org/bemade/odoo-dev
|
|
6
6
|
Project-URL: Repository, https://git.bemade.org/bemade/odoo-dev
|
|
@@ -175,8 +175,20 @@ something other than the current pin.
|
|
|
175
175
|
|
|
176
176
|
`vendor check` verifies, per addon: the vendored files byte-match the pinned
|
|
177
177
|
commit; a `version` tag (if set) still resolves to that commit; every manifest
|
|
178
|
-
`external_dependencies['python']` is named in `requirements.txt`;
|
|
179
|
-
name collides between `addons/` and `vendored
|
|
178
|
+
`external_dependencies['python']` is named in `requirements.txt`; no addon
|
|
179
|
+
name collides between `addons/` and `vendored/`; and no file under `vendored/` is
|
|
180
|
+
gitignored. Python bytecode (`__pycache__/`, `*.pyc`) is excluded from the byte
|
|
181
|
+
comparison, so running the test suite doesn't turn the check red.
|
|
182
|
+
|
|
183
|
+
That last assertion catches a trap specific to migration: repo-wide ignore rules
|
|
184
|
+
(`node_modules`, `package.json`, …) are harmless while shared addons live in
|
|
185
|
+
submodules, but they start applying the moment those addons become real files
|
|
186
|
+
under `vendored/` — and `git add` then skips the matching files without a word,
|
|
187
|
+
so the committed tree is incomplete while everything looks fine locally.
|
|
188
|
+
`vendor migrate` appends a `!vendored/**` negation to `.gitignore` when it
|
|
189
|
+
detects the situation. Pins themselves come from the gitlink the branch records,
|
|
190
|
+
not from whatever the submodule happens to be checked out at; migrate refuses to
|
|
191
|
+
run against uninitialized submodules.
|
|
180
192
|
|
|
181
193
|
`vendor update` is the **pull** side: each client owns its pins. For every addon
|
|
182
194
|
that tracks upstream — a `version` (bump to the newest `<addon>/<version>` tag) or
|
|
@@ -161,8 +161,20 @@ something other than the current pin.
|
|
|
161
161
|
|
|
162
162
|
`vendor check` verifies, per addon: the vendored files byte-match the pinned
|
|
163
163
|
commit; a `version` tag (if set) still resolves to that commit; every manifest
|
|
164
|
-
`external_dependencies['python']` is named in `requirements.txt`;
|
|
165
|
-
name collides between `addons/` and `vendored
|
|
164
|
+
`external_dependencies['python']` is named in `requirements.txt`; no addon
|
|
165
|
+
name collides between `addons/` and `vendored/`; and no file under `vendored/` is
|
|
166
|
+
gitignored. Python bytecode (`__pycache__/`, `*.pyc`) is excluded from the byte
|
|
167
|
+
comparison, so running the test suite doesn't turn the check red.
|
|
168
|
+
|
|
169
|
+
That last assertion catches a trap specific to migration: repo-wide ignore rules
|
|
170
|
+
(`node_modules`, `package.json`, …) are harmless while shared addons live in
|
|
171
|
+
submodules, but they start applying the moment those addons become real files
|
|
172
|
+
under `vendored/` — and `git add` then skips the matching files without a word,
|
|
173
|
+
so the committed tree is incomplete while everything looks fine locally.
|
|
174
|
+
`vendor migrate` appends a `!vendored/**` negation to `.gitignore` when it
|
|
175
|
+
detects the situation. Pins themselves come from the gitlink the branch records,
|
|
176
|
+
not from whatever the submodule happens to be checked out at; migrate refuses to
|
|
177
|
+
run against uninitialized submodules.
|
|
166
178
|
|
|
167
179
|
`vendor update` is the **pull** side: each client owns its pins. For every addon
|
|
168
180
|
that tracks upstream — a `version` (bump to the newest `<addon>/<version>` tag) or
|
|
@@ -235,17 +235,18 @@ def test(
|
|
|
235
235
|
break
|
|
236
236
|
|
|
237
237
|
# Auto-load exclusions from repo_deps.yaml when not explicitly provided
|
|
238
|
-
|
|
238
|
+
no_ci_modules: list[str] = []
|
|
239
|
+
if repo_deps_file is not None:
|
|
239
240
|
try:
|
|
240
241
|
import yaml
|
|
241
242
|
|
|
242
243
|
data = yaml.safe_load(repo_deps_file.read_text()) or {}
|
|
243
|
-
|
|
244
|
-
if no_ci:
|
|
245
|
-
exclude = ",".join(no_ci)
|
|
246
|
-
info(f"Excluding (from {repo_deps_file} no_ci): {exclude}")
|
|
244
|
+
no_ci_modules = list(data.get("no_ci", []) or [])
|
|
247
245
|
except Exception:
|
|
248
|
-
|
|
246
|
+
no_ci_modules = []
|
|
247
|
+
if exclude is None and no_ci_modules:
|
|
248
|
+
exclude = ",".join(no_ci_modules)
|
|
249
|
+
info(f"Excluding (from {repo_deps_file} no_ci): {exclude}")
|
|
249
250
|
|
|
250
251
|
# Verify setup
|
|
251
252
|
if not cfg.venv_path.exists():
|
|
@@ -376,6 +377,9 @@ def test(
|
|
|
376
377
|
"--stop-after-init",
|
|
377
378
|
"--http-port",
|
|
378
379
|
str(http_port),
|
|
380
|
+
# No need to fork prefork workers for a stop-after-init install.
|
|
381
|
+
"--workers",
|
|
382
|
+
"0",
|
|
379
383
|
],
|
|
380
384
|
)
|
|
381
385
|
|
|
@@ -422,11 +426,36 @@ def test(
|
|
|
422
426
|
"--stop-after-init",
|
|
423
427
|
"--http-port",
|
|
424
428
|
str(http_port),
|
|
429
|
+
# HttpCase needs the threaded server (ThreadedServer.httpd);
|
|
430
|
+
# a prefork/multi-worker conf raises
|
|
431
|
+
# "'PreforkServer' object has no attribute 'httpd'" in
|
|
432
|
+
# setUpClass. Force single-process regardless of odoo.conf.
|
|
433
|
+
"--workers",
|
|
434
|
+
"0",
|
|
435
|
+
# A restrictive dbfilter in odoo.conf (e.g. `dbfilter = ^rwi$`)
|
|
436
|
+
# never matches the ephemeral test DB, so HttpCase requests get
|
|
437
|
+
# "dbfilter rejects it; logging session out" -> auth='user'
|
|
438
|
+
# routes 404 and JSON endpoints return non-JSON. Scope the
|
|
439
|
+
# filter to this run's test DB so the test server serves it.
|
|
440
|
+
f"--db-filter=^{db_name}$",
|
|
425
441
|
]
|
|
426
442
|
)
|
|
427
443
|
|
|
428
|
-
|
|
429
|
-
|
|
444
|
+
# Excluding no_ci modules from the tested-module *list* is not enough:
|
|
445
|
+
# they still install as transitive deps, and Odoo's button_upgrade
|
|
446
|
+
# cascades 'to upgrade' to downstream dependents of any upgraded target
|
|
447
|
+
# module (`-u modules`). A no_ci module that depends on a tested module
|
|
448
|
+
# thus gets re-upgraded and its post_install tests run. Exclude their
|
|
449
|
+
# tests explicitly via Odoo test-tags. The TagsSelector auto-adds
|
|
450
|
+
# '+standard' when only '-' filters are present, so this keeps running
|
|
451
|
+
# every standard test except those belonging to a no_ci module.
|
|
452
|
+
effective_test_tags = test_tags
|
|
453
|
+
if effective_test_tags is None and no_ci_modules:
|
|
454
|
+
effective_test_tags = ",".join(f"-/{m}" for m in no_ci_modules)
|
|
455
|
+
info(f"Excluding no_ci module tests via --test-tags: {effective_test_tags}")
|
|
456
|
+
|
|
457
|
+
if effective_test_tags:
|
|
458
|
+
test_cmd.extend(["--test-tags", effective_test_tags])
|
|
430
459
|
|
|
431
460
|
result = subprocess.run(test_cmd)
|
|
432
461
|
test_exit_code = result.returncode
|
|
@@ -14,16 +14,22 @@ from typing import Annotated, Optional
|
|
|
14
14
|
import typer
|
|
15
15
|
|
|
16
16
|
from odoo_dev.config import load_config
|
|
17
|
-
from odoo_dev.utils.console import error, info, success
|
|
17
|
+
from odoo_dev.utils.console import error, info, success, warning
|
|
18
18
|
from odoo_dev.vendor.develop import (
|
|
19
19
|
DevelopError,
|
|
20
20
|
develop_state,
|
|
21
|
+
ensure_addons_path,
|
|
21
22
|
start_develop,
|
|
22
23
|
stop_develop,
|
|
23
24
|
)
|
|
24
25
|
from odoo_dev.vendor.edit import EditError, add_addon, bump_addon
|
|
25
26
|
from odoo_dev.vendor.lock import Lockfile
|
|
26
|
-
from odoo_dev.vendor.migrate import
|
|
27
|
+
from odoo_dev.vendor.migrate import (
|
|
28
|
+
MigrateError,
|
|
29
|
+
ensure_vendored_not_ignored,
|
|
30
|
+
migrate_repo,
|
|
31
|
+
plan_migration,
|
|
32
|
+
)
|
|
27
33
|
from odoo_dev.vendor.sync import sync_addons
|
|
28
34
|
from odoo_dev.vendor.update import apply_update, find_updates
|
|
29
35
|
from odoo_dev.vendor.verify import verify
|
|
@@ -146,8 +152,23 @@ def migrate_cmd(
|
|
|
146
152
|
) -> None:
|
|
147
153
|
"""Convert addons/ symlinks into .repos submodules to vendored/ + addons.lock."""
|
|
148
154
|
cfg = load_config()
|
|
149
|
-
|
|
155
|
+
try:
|
|
150
156
|
plans = plan_migration(cfg.project_dir, addon or None)
|
|
157
|
+
except MigrateError as exc:
|
|
158
|
+
error(str(exc))
|
|
159
|
+
raise typer.Exit(2)
|
|
160
|
+
|
|
161
|
+
# The pin is the gitlink the branch records. Say so when the submodule is
|
|
162
|
+
# checked out somewhere else, since that checkout is what the dev sees.
|
|
163
|
+
for p in plans:
|
|
164
|
+
if p["checkout"] and p["checkout"] != p["commit"]:
|
|
165
|
+
warning(
|
|
166
|
+
f"{p['name']}: {p['sub_path']} is checked out at "
|
|
167
|
+
f"{p['checkout'][:12]} but this branch records "
|
|
168
|
+
f"{p['commit'][:12]} — pinning the recorded commit."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if dry_run:
|
|
151
172
|
if not plans:
|
|
152
173
|
info("Nothing to migrate (no symlinked submodule addons found).")
|
|
153
174
|
return
|
|
@@ -159,6 +180,23 @@ def migrate_cmd(
|
|
|
159
180
|
|
|
160
181
|
lock, unused = migrate_repo(cfg.project_dir, addons=addon or None, deinit=deinit)
|
|
161
182
|
success(f"Migrated {len(lock.entries)} addon(s) to vendored/ (see addons.lock).")
|
|
183
|
+
|
|
184
|
+
# Wire vendored/ into the local conf's addons_path. A fresh `setup` adds it,
|
|
185
|
+
# but `setup` won't overwrite an existing conf, so a repo vendored for the
|
|
186
|
+
# first time would otherwise leave Odoo unable to find the new addons
|
|
187
|
+
# ("invalid module names, ignored"). Local conf only — never Docker/CI.
|
|
188
|
+
if cfg.config_file.exists() and cfg.vendored_dir.is_dir():
|
|
189
|
+
if ensure_addons_path(cfg.config_file, str(cfg.vendored_dir)):
|
|
190
|
+
info(f"Added {cfg.vendored_dir} to {cfg.config_file} addons_path.")
|
|
191
|
+
|
|
192
|
+
# Repo-wide ignore rules that were harmless against submodule content now
|
|
193
|
+
# apply to real files under vendored/, where git would drop them from the
|
|
194
|
+
# commit without a word. Negate them at the point of damage.
|
|
195
|
+
if ensure_vendored_not_ignored(cfg.project_dir):
|
|
196
|
+
info(
|
|
197
|
+
"Added a '!vendored/**' negation to .gitignore (rules matched "
|
|
198
|
+
"vendored files, which git would have silently skipped)."
|
|
199
|
+
)
|
|
162
200
|
if unused:
|
|
163
201
|
info("")
|
|
164
202
|
if deinit:
|
|
@@ -167,6 +167,21 @@ def remove_addons_path(conf_file: Path, path: str) -> None:
|
|
|
167
167
|
_write_addons_path(conf_file, [p for p in read_addons_path(conf_file) if p != path])
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
def ensure_addons_path(conf_file: Path, path: str) -> bool:
|
|
171
|
+
"""Append ``path`` to the conf's addons_path if it isn't already listed.
|
|
172
|
+
|
|
173
|
+
Idempotent; returns True only when it actually added the entry. Used by
|
|
174
|
+
``vendor migrate`` to wire ``vendored/`` into a conf that predates vendoring
|
|
175
|
+
(``setup`` adds it for fresh confs but never overwrites an existing one).
|
|
176
|
+
"""
|
|
177
|
+
paths = read_addons_path(conf_file)
|
|
178
|
+
if path in paths:
|
|
179
|
+
return False
|
|
180
|
+
paths.append(path)
|
|
181
|
+
_write_addons_path(conf_file, paths)
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
|
|
170
185
|
# --- overlay / gitignore / state ------------------------------------------------
|
|
171
186
|
|
|
172
187
|
|
|
@@ -76,8 +76,15 @@ def bump_addon(
|
|
|
76
76
|
entry = lock.entries[name]
|
|
77
77
|
sha = _resolve_pin(name, entry.source, version, commit, entry.branch, cache_dir)
|
|
78
78
|
entry.commit = sha
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
# ``version`` is the tag-intent metadata, and ``vendor check``'s moved-tag
|
|
80
|
+
# tripwire (verify.py) requires ``<name>/<version>`` to still resolve to
|
|
81
|
+
# ``commit``. A ``--commit`` / branch-HEAD bump moves the pin off the tagged
|
|
82
|
+
# commit, so the old ``version`` is now stale and would (correctly) fail the
|
|
83
|
+
# tripwire. Set it to whatever the bump was keyed on: the new tag for a
|
|
84
|
+
# ``--version`` bump, or ``None`` for a raw ``--commit`` / branch bump — a
|
|
85
|
+
# commit pin carries no version metadata (matching how ``migrate`` renders
|
|
86
|
+
# untagged pins).
|
|
87
|
+
entry.version = version
|
|
81
88
|
lock.entries[name] = entry
|
|
82
89
|
lock.dump(project_dir / "addons.lock")
|
|
83
90
|
sync_addons(project_dir, lock, cache_dir=cache_dir, names=[name])
|
|
@@ -5,6 +5,11 @@ modes and symlinks survive verbatim — the vendored copy is byte-identical to t
|
|
|
5
5
|
source subtree. :func:`tree_diff` then compares two trees on content, the
|
|
6
6
|
executable bit, and symlink targets (a plain ``diff -r`` silently ignores the
|
|
7
7
|
latter two and follows symlinks), which is what makes ``vendor check`` trustworthy.
|
|
8
|
+
|
|
9
|
+
Python bytecode (``__pycache__/``, ``*.pyc``, ``*.pyo``) is excluded from the
|
|
10
|
+
comparison: running or testing a vendored addon locally compiles it in place, and
|
|
11
|
+
those artifacts are gitignored build output that is neither part of the pinned
|
|
12
|
+
source nor present in a fresh CI checkout.
|
|
8
13
|
"""
|
|
9
14
|
|
|
10
15
|
from __future__ import annotations
|
|
@@ -60,12 +65,18 @@ def extract_subtree(repo: Path, commit: str, addon: str, dest: Path) -> None:
|
|
|
60
65
|
)
|
|
61
66
|
|
|
62
67
|
|
|
68
|
+
_BYTECODE_DIRS = {"__pycache__"}
|
|
69
|
+
_BYTECODE_SUFFIXES = (".pyc", ".pyo")
|
|
70
|
+
|
|
71
|
+
|
|
63
72
|
def _entries(root: Path) -> dict:
|
|
64
73
|
"""Map each path relative to ``root`` to a comparable fingerprint.
|
|
65
74
|
|
|
66
75
|
- symlink -> ("link", target) (never dereferenced)
|
|
67
76
|
- file -> ("file", exec_bit, sha1)
|
|
68
77
|
- dir -> ("dir",)
|
|
78
|
+
|
|
79
|
+
Python bytecode is skipped entirely (see the module docstring).
|
|
69
80
|
"""
|
|
70
81
|
root = Path(root)
|
|
71
82
|
out: dict = {}
|
|
@@ -73,6 +84,8 @@ def _entries(root: Path) -> dict:
|
|
|
73
84
|
# Do not descend into symlinked directories; record them as links.
|
|
74
85
|
real_dirs = []
|
|
75
86
|
for d in dirnames:
|
|
87
|
+
if d in _BYTECODE_DIRS:
|
|
88
|
+
continue
|
|
76
89
|
p = Path(dirpath) / d
|
|
77
90
|
rel = str(p.relative_to(root))
|
|
78
91
|
if p.is_symlink():
|
|
@@ -82,6 +95,8 @@ def _entries(root: Path) -> dict:
|
|
|
82
95
|
real_dirs.append(d)
|
|
83
96
|
dirnames[:] = real_dirs
|
|
84
97
|
for f in filenames:
|
|
98
|
+
if f.endswith(_BYTECODE_SUFFIXES):
|
|
99
|
+
continue
|
|
85
100
|
p = Path(dirpath) / f
|
|
86
101
|
rel = str(p.relative_to(root))
|
|
87
102
|
if p.is_symlink():
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""``vendor migrate``: convert a submodule+symlink repo to a vendored one.
|
|
2
2
|
|
|
3
3
|
Reads the ``addons/<addon>`` symlinks that point into ``.repos/*`` submodules,
|
|
4
|
-
derives a per-addon pin (submodule remote URL = source,
|
|
5
|
-
manifest version -> version when
|
|
4
|
+
derives a per-addon pin (submodule remote URL = source, recorded gitlink = commit,
|
|
5
|
+
manifest version -> version ONLY when the ``<addon>/<version>`` tag resolves to
|
|
6
|
+
the pinned commit; a commit that isn't the tagged release (a pin behind or ahead
|
|
7
|
+
of the tag, or any intermediate commit) is left commit-only, no version),
|
|
6
8
|
writes ``addons.lock``, materializes ``vendored/<addon>/`` from the submodule,
|
|
7
9
|
and removes the now-redundant symlink. Client-private real dirs under ``addons/``
|
|
8
10
|
are left untouched; only symlinks into submodules are migrated. Submodule removal
|
|
@@ -19,13 +21,47 @@ from typing import Iterable, Optional
|
|
|
19
21
|
|
|
20
22
|
from odoo_dev.utils.manifest import manifest_path, read_version
|
|
21
23
|
from odoo_dev.vendor.lock import LockEntry, Lockfile
|
|
24
|
+
from odoo_dev.vendor.sources import tag_resolves_to
|
|
22
25
|
from odoo_dev.vendor.sync import sync_addons
|
|
26
|
+
from odoo_dev.vendor.verify import gitignored_under_vendored
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
class MigrateError(Exception):
|
|
26
30
|
pass
|
|
27
31
|
|
|
28
32
|
|
|
33
|
+
_NEGATION = "!vendored/**"
|
|
34
|
+
_NEGATION_BLOCK = (
|
|
35
|
+
"\n# Vendored addons must match addons.lock byte-for-byte, so no repo-wide\n"
|
|
36
|
+
"# ignore rule may strip files out of them. Keep last.\n"
|
|
37
|
+
f"{_NEGATION}\n"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_vendored_not_ignored(project_dir: Path) -> bool:
|
|
42
|
+
"""Append a ``!vendored/**`` negation when ``.gitignore`` would eat vendored files.
|
|
43
|
+
|
|
44
|
+
``migrate`` is the command that creates the hazard — it turns submodule content
|
|
45
|
+
(governed by the submodule's own ignore rules) into real files under the
|
|
46
|
+
superproject's — so it is the right place to repair it, in the same shape as
|
|
47
|
+
``ensure_addons_path`` fixing up ``conf/odoo.conf``. Idempotent; returns True
|
|
48
|
+
only when it actually wrote.
|
|
49
|
+
"""
|
|
50
|
+
project_dir = Path(project_dir)
|
|
51
|
+
if not gitignored_under_vendored(project_dir):
|
|
52
|
+
return False
|
|
53
|
+
gitignore = project_dir / ".gitignore"
|
|
54
|
+
text = gitignore.read_text() if gitignore.exists() else ""
|
|
55
|
+
if any(line.strip() == _NEGATION for line in text.splitlines()):
|
|
56
|
+
# Already negated yet still ignored: a later rule re-excludes the path, or
|
|
57
|
+
# the negation cannot apply. Leave it — `vendor check` reports the files.
|
|
58
|
+
return False
|
|
59
|
+
if text and not text.endswith("\n"):
|
|
60
|
+
text += "\n"
|
|
61
|
+
gitignore.write_text(text + _NEGATION_BLOCK)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
29
65
|
def read_gitmodules(project_dir: Path) -> list:
|
|
30
66
|
"""Return [(name, path, url)] for each submodule declared in .gitmodules."""
|
|
31
67
|
gm = Path(project_dir) / ".gitmodules"
|
|
@@ -54,29 +90,82 @@ def read_gitmodules(project_dir: Path) -> list:
|
|
|
54
90
|
]
|
|
55
91
|
|
|
56
92
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
93
|
+
def _gitlink_commit(project_dir: Path, sub_path: str) -> str:
|
|
94
|
+
"""The commit the SUPERPROJECT records for ``sub_path`` — the authoritative pin.
|
|
95
|
+
|
|
96
|
+
Read from the superproject tree, never from the submodule working copy. An
|
|
97
|
+
uninitialized submodule is an empty directory, so ``git -C <sub> rev-parse
|
|
98
|
+
HEAD`` walks up to the enclosing repo, returns the SUPERPROJECT's head and
|
|
99
|
+
exits 0 — pinning every addon across every source repo to one wrong sha, in a
|
|
100
|
+
lockfile that ``vendor check`` then happily validates against itself.
|
|
101
|
+
``ls-tree`` cannot walk up, and the ``160000`` mode proves it is a gitlink.
|
|
102
|
+
"""
|
|
103
|
+
res = subprocess.run(
|
|
104
|
+
["git", "-C", str(project_dir), "ls-tree", "HEAD", "--", sub_path],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
)
|
|
108
|
+
line = res.stdout.strip()
|
|
109
|
+
if res.returncode != 0 or not line:
|
|
110
|
+
raise MigrateError(
|
|
111
|
+
f"{sub_path}: no submodule recorded at this path in HEAD — cannot "
|
|
112
|
+
f"derive a pin (commit the submodule, or fix .gitmodules)."
|
|
113
|
+
)
|
|
114
|
+
mode, _, rest = line.partition(" ")
|
|
115
|
+
obj_type, _, rest = rest.partition(" ")
|
|
116
|
+
sha = rest.split("\t", 1)[0].strip()
|
|
117
|
+
if mode != "160000" or obj_type != "commit":
|
|
118
|
+
raise MigrateError(
|
|
119
|
+
f"{sub_path}: recorded in HEAD as a {obj_type}, not a submodule "
|
|
120
|
+
f"gitlink — cannot derive a pin."
|
|
121
|
+
)
|
|
122
|
+
return sha
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _submodule_checkout(project_dir: Path, sub_path: str) -> Optional[str]:
|
|
126
|
+
"""The commit the submodule working copy is actually on, or None if absent.
|
|
127
|
+
|
|
128
|
+
Only meaningful for an initialized submodule (which owns a ``.git``); the
|
|
129
|
+
caller uses it to warn when the checkout has drifted off the gitlink.
|
|
130
|
+
"""
|
|
131
|
+
sub_root = Path(project_dir) / sub_path
|
|
132
|
+
if not (sub_root / ".git").exists():
|
|
133
|
+
return None
|
|
134
|
+
res = subprocess.run(
|
|
135
|
+
["git", "-C", str(sub_root), "rev-parse", "HEAD"],
|
|
61
136
|
capture_output=True,
|
|
62
137
|
text=True,
|
|
63
|
-
)
|
|
138
|
+
)
|
|
139
|
+
return res.stdout.strip() if res.returncode == 0 else None
|
|
64
140
|
|
|
65
141
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
== 0
|
|
142
|
+
def uninitialized_submodules(project_dir: Path) -> list:
|
|
143
|
+
"""Submodule paths git reports as not checked out (``-`` prefix)."""
|
|
144
|
+
res = subprocess.run(
|
|
145
|
+
["git", "-C", str(project_dir), "submodule", "status"],
|
|
146
|
+
capture_output=True,
|
|
147
|
+
text=True,
|
|
73
148
|
)
|
|
149
|
+
paths = []
|
|
150
|
+
for line in res.stdout.splitlines():
|
|
151
|
+
if not line.startswith("-"):
|
|
152
|
+
continue
|
|
153
|
+
parts = line[1:].split()
|
|
154
|
+
if len(parts) >= 2:
|
|
155
|
+
paths.append(parts[1])
|
|
156
|
+
return paths
|
|
74
157
|
|
|
75
158
|
|
|
76
159
|
def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) -> list:
|
|
77
160
|
"""Return a list of planned pins without changing anything.
|
|
78
161
|
|
|
79
|
-
Each item: dict(name, source, commit, version, symlink, sub_path).
|
|
162
|
+
Each item: dict(name, source, commit, version, checkout, symlink, sub_path).
|
|
163
|
+
``checkout`` is the submodule's actual HEAD, so the caller can warn when it
|
|
164
|
+
has drifted off the recorded gitlink the pin comes from.
|
|
165
|
+
|
|
166
|
+
Raises :class:`MigrateError` if any submodule being migrated is uninitialized:
|
|
167
|
+
its addon content and manifest are simply not on disk, and its pin cannot be
|
|
168
|
+
trusted to mean anything.
|
|
80
169
|
"""
|
|
81
170
|
project_dir = Path(project_dir)
|
|
82
171
|
addons_dir = project_dir / "addons"
|
|
@@ -84,6 +173,8 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
|
|
|
84
173
|
return []
|
|
85
174
|
subs = read_gitmodules(project_dir)
|
|
86
175
|
only = set(addons) if addons is not None else None
|
|
176
|
+
uninit = set(uninitialized_submodules(project_dir))
|
|
177
|
+
blocked = []
|
|
87
178
|
|
|
88
179
|
plans = []
|
|
89
180
|
for link in sorted(addons_dir.iterdir()):
|
|
@@ -106,6 +197,9 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
|
|
|
106
197
|
if sub is None:
|
|
107
198
|
continue # symlink not into a known submodule; skip
|
|
108
199
|
s_name, s_path, s_url = sub
|
|
200
|
+
if s_path in uninit:
|
|
201
|
+
blocked.append((name, s_path))
|
|
202
|
+
continue
|
|
109
203
|
sub_root = (project_dir / s_path).resolve()
|
|
110
204
|
subpath = real.relative_to(sub_root)
|
|
111
205
|
if str(subpath) != name:
|
|
@@ -113,12 +207,18 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
|
|
|
113
207
|
f"{name}: addon sits at '{subpath}' inside submodule {s_path}, not at "
|
|
114
208
|
f"the root as '{name}'. Nested addons aren't handled yet — migrate by hand."
|
|
115
209
|
)
|
|
116
|
-
commit =
|
|
210
|
+
commit = _gitlink_commit(project_dir, s_path)
|
|
117
211
|
version = None
|
|
118
212
|
mf = manifest_path(real)
|
|
119
213
|
if mf is not None:
|
|
120
214
|
v = read_version(mf.read_text())
|
|
121
|
-
|
|
215
|
+
# Only claim a ``version`` when the tag actually points at the pinned
|
|
216
|
+
# commit. Checking mere tag EXISTENCE was wrong: the same manifest
|
|
217
|
+
# version can live at several commits (the tag is cut at one of
|
|
218
|
+
# them), so a pin at any other commit with that version would carry a
|
|
219
|
+
# ``version`` whose tag resolves elsewhere — tripping ``vendor
|
|
220
|
+
# check``'s moved-tag guard. A non-release pin is commit-only.
|
|
221
|
+
if v is not None and tag_resolves_to(sub_root, f"{name}/{v}", commit):
|
|
122
222
|
version = v
|
|
123
223
|
plans.append(
|
|
124
224
|
dict(
|
|
@@ -126,10 +226,20 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
|
|
|
126
226
|
source=s_url,
|
|
127
227
|
commit=commit,
|
|
128
228
|
version=version,
|
|
229
|
+
checkout=_submodule_checkout(project_dir, s_path),
|
|
129
230
|
symlink=link,
|
|
130
231
|
sub_path=s_path,
|
|
131
232
|
)
|
|
132
233
|
)
|
|
234
|
+
|
|
235
|
+
if blocked:
|
|
236
|
+
paths = sorted({s_path for _, s_path in blocked})
|
|
237
|
+
names = ", ".join(sorted(name for name, _ in blocked))
|
|
238
|
+
raise MigrateError(
|
|
239
|
+
f"uninitialized submodule(s): {', '.join(paths)} — needed by {names}. "
|
|
240
|
+
f"Run 'git submodule update --init' first; migrating without them "
|
|
241
|
+
f"would pin every addon to the superproject's own HEAD."
|
|
242
|
+
)
|
|
133
243
|
return plans
|
|
134
244
|
|
|
135
245
|
|
|
@@ -24,27 +24,46 @@ def _version_key(v: str) -> tuple:
|
|
|
24
24
|
return tuple(int(p) if p.isdigit() else 0 for p in v.replace("-", ".").split("."))
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _series(v: str) -> str:
|
|
28
|
+
"""The major-series segment of a version — the cross-series guard.
|
|
29
|
+
|
|
30
|
+
An Odoo addon carries both ``18.0.*`` and ``19.0.*`` version tags in its source;
|
|
31
|
+
version-tracking must stay within the pin's own series (``18`` vs ``19``), since
|
|
32
|
+
moving across Odoo series is a deliberate migration, never an auto-update.
|
|
33
|
+
"""
|
|
34
|
+
return v.split(".", 1)[0]
|
|
35
|
+
|
|
36
|
+
|
|
27
37
|
def list_addon_versions(
|
|
28
|
-
source: str,
|
|
38
|
+
source: str,
|
|
39
|
+
addon: str,
|
|
40
|
+
cache_dir: Optional[Path] = None,
|
|
41
|
+
series: Optional[str] = None,
|
|
29
42
|
) -> list[str]:
|
|
30
|
-
"""
|
|
43
|
+
"""``<addon>/<version>`` tag versions in ``source``, optionally same-series only."""
|
|
31
44
|
repo = _ensure_clone(source, cache_dir)
|
|
32
45
|
r = subprocess.run(
|
|
33
46
|
["git", "-C", str(repo), "tag", "-l", f"{addon}/*"],
|
|
34
47
|
capture_output=True,
|
|
35
48
|
text=True,
|
|
36
49
|
)
|
|
37
|
-
|
|
50
|
+
versions = [
|
|
38
51
|
t.split("/", 1)[1]
|
|
39
52
|
for t in r.stdout.split()
|
|
40
53
|
if t.startswith(f"{addon}/") and "/" in t
|
|
41
54
|
]
|
|
55
|
+
if series is not None:
|
|
56
|
+
versions = [v for v in versions if _series(v) == series]
|
|
57
|
+
return versions
|
|
42
58
|
|
|
43
59
|
|
|
44
60
|
def latest_version(
|
|
45
|
-
source: str,
|
|
61
|
+
source: str,
|
|
62
|
+
addon: str,
|
|
63
|
+
cache_dir: Optional[Path] = None,
|
|
64
|
+
series: Optional[str] = None,
|
|
46
65
|
) -> Optional[str]:
|
|
47
|
-
versions = list_addon_versions(source, addon, cache_dir)
|
|
66
|
+
versions = list_addon_versions(source, addon, cache_dir, series)
|
|
48
67
|
return max(versions, key=_version_key) if versions else None
|
|
49
68
|
|
|
50
69
|
|
|
@@ -65,8 +84,8 @@ def find_updates(
|
|
|
65
84
|
for name, e in lock.entries.items():
|
|
66
85
|
if only is not None and name not in only:
|
|
67
86
|
continue
|
|
68
|
-
if e.version: # version-tracked
|
|
69
|
-
latest = latest_version(e.source, name, cache_dir)
|
|
87
|
+
if e.version: # version-tracked (within the pin's own major series)
|
|
88
|
+
latest = latest_version(e.source, name, cache_dir, series=_series(e.version))
|
|
70
89
|
if latest and _version_key(latest) > _version_key(e.version):
|
|
71
90
|
updates.append(
|
|
72
91
|
{
|