odoo-dev 1.4.0__tar.gz → 1.5.0__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.
Files changed (69) hide show
  1. odoo_dev-1.5.0/.release-please-manifest.json +3 -0
  2. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/CHANGELOG.md +12 -0
  3. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/PKG-INFO +13 -1
  4. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/README.md +12 -0
  5. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/pyproject.toml +1 -1
  6. odoo_dev-1.5.0/src/odoo_dev/__init__.py +3 -0
  7. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/run.py +20 -0
  8. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/setup.py +123 -2
  9. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/config.py +34 -0
  10. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/materialize.py +20 -3
  11. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/verify.py +49 -4
  12. odoo_dev-1.5.0/tests/test_drop_filestore.py +49 -0
  13. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_integration.py +9 -0
  14. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_setup.py +8 -1
  15. odoo_dev-1.5.0/tests/test_setup_data_dir.py +179 -0
  16. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_verify.py +60 -0
  17. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/uv.lock +1 -1
  18. odoo_dev-1.4.0/.release-please-manifest.json +0 -3
  19. odoo_dev-1.4.0/src/odoo_dev/__init__.py +0 -3
  20. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/.github/workflows/release-please.yml +0 -0
  21. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/.gitignore +0 -0
  22. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/.gitmodules +0 -0
  23. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/.python-version +0 -0
  24. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/release-please-config.json +0 -0
  25. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/cli.py +0 -0
  26. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/__init__.py +0 -0
  27. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/bump.py +0 -0
  28. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/db.py +0 -0
  29. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/docker.py +0 -0
  30. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/git.py +0 -0
  31. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/vendor.py +0 -0
  32. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/preflight.py +0 -0
  33. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/__init__.py +0 -0
  34. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
  35. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
  36. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
  37. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
  38. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
  39. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
  40. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
  41. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/__init__.py +0 -0
  42. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/console.py +0 -0
  43. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/manifest.py +0 -0
  44. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/__init__.py +0 -0
  45. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/develop.py +0 -0
  46. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/edit.py +0 -0
  47. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/lock.py +0 -0
  48. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/migrate.py +0 -0
  49. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/sources.py +0 -0
  50. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/sync.py +0 -0
  51. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/update.py +0 -0
  52. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/__init__.py +0 -0
  53. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/fixtures/__init__.py +0 -0
  54. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_bump.py +0 -0
  55. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_cli.py +0 -0
  56. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_config.py +0 -0
  57. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_db.py +0 -0
  58. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_db_config.py +0 -0
  59. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_drop_database.py +0 -0
  60. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_git_hygiene.py +0 -0
  61. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_preflight.py +0 -0
  62. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_run.py +0 -0
  63. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_setup_db.py +0 -0
  64. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_develop.py +0 -0
  65. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_edit.py +0 -0
  66. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_lock.py +0 -0
  67. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_materialize.py +0 -0
  68. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_migrate.py +0 -0
  69. {odoo_dev-1.4.0 → odoo_dev-1.5.0}/tests/test_vendor_update.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.5.0"
3
+ }
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/bemade/odoo-dev/compare/v1.4.0...v1.5.0) (2026-10-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * **setup:** project-local data_dir, addon manifest deps, ADMIN_PASSWD ([#23](https://github.com/bemade/odoo-dev/issues/23)) ([60c931e](https://github.com/bemade/odoo-dev/commit/60c931e3cf38c025895eb183d1a3a1ee929931e2))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **vendor:** ignore local python bytecode in vendor check, fail on tracked bytecode ([#25](https://github.com/bemade/odoo-dev/issues/25)) ([8d679af](https://github.com/bemade/odoo-dev/commit/8d679af962d96002a1ff978e6229ffeaf063250c))
14
+
3
15
  ## [1.4.0](https://github.com/bemade/odoo-dev/compare/v1.3.0...v1.4.0) (2026-09-04)
4
16
 
5
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: odoo-dev
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Odoo Development Environment Helper
5
5
  Project-URL: Homepage, https://github.com/bemade/odoo-dev
6
6
  Project-URL: Repository, https://github.com/bemade/odoo-dev
@@ -236,8 +236,20 @@ PYTHON_VERSION=3.12
236
236
  # DB_PORT=5432
237
237
  # DB_USER=odoo
238
238
  # DB_PASSWORD=odoo
239
+
240
+ # Optional — Odoo master password, written into conf/odoo.conf by `setup`.
241
+ # Defaults to "admin".
242
+ # ADMIN_PASSWD=admin
239
243
  ```
240
244
 
245
+ `setup` also points `data_dir` at a project-local `.odoo-data/` (git-ignored)
246
+ rather than the shared `~/.local/share/Odoo`, so filestores, sessions and the
247
+ addons cache stay scoped to the checkout they belong to.
248
+
249
+ Python packages declared in your addons' manifests — `external_dependencies`
250
+ *and* the OCA `test_external_dependencies` convention, across both `addons/`
251
+ and `vendored/` — are installed into the venv by `setup`.
252
+
241
253
  ## Requirements
242
254
 
243
255
  - Python 3.12+
@@ -221,8 +221,20 @@ PYTHON_VERSION=3.12
221
221
  # DB_PORT=5432
222
222
  # DB_USER=odoo
223
223
  # DB_PASSWORD=odoo
224
+
225
+ # Optional — Odoo master password, written into conf/odoo.conf by `setup`.
226
+ # Defaults to "admin".
227
+ # ADMIN_PASSWD=admin
224
228
  ```
225
229
 
230
+ `setup` also points `data_dir` at a project-local `.odoo-data/` (git-ignored)
231
+ rather than the shared `~/.local/share/Odoo`, so filestores, sessions and the
232
+ addons cache stay scoped to the checkout they belong to.
233
+
234
+ Python packages declared in your addons' manifests — `external_dependencies`
235
+ *and* the OCA `test_external_dependencies` convention, across both `addons/`
236
+ and `vendored/` — are installed into the venv by `setup`.
237
+
226
238
  ## Requirements
227
239
 
228
240
  - Python 3.12+
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "odoo-dev"
3
- version = "1.4.0"
3
+ version = "1.5.0"
4
4
  description = "Odoo Development Environment Helper"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,3 @@
1
+ """Odoo Development Environment Helper."""
2
+
3
+ __version__ = "1.5.0" # x-release-please-version
@@ -1,6 +1,7 @@
1
1
  """Local Odoo runtime commands."""
2
2
 
3
3
  import os
4
+ import shutil
4
5
  import signal
5
6
  import subprocess
6
7
  import sys
@@ -582,6 +583,25 @@ def _drop_database(cfg, db_name: str) -> None:
582
583
  warning(f"Failed to drop test database {db_name}")
583
584
  if result.stderr:
584
585
  warning(result.stderr.strip())
586
+ return
587
+
588
+ # dropdb only removes the database. odoo-bin's own `db drop` deletes the
589
+ # filestore too, so the fallback has to, or every test run leaks a
590
+ # filestore directory that nothing ever cleans up.
591
+ _drop_filestore(cfg, db_name)
592
+
593
+
594
+ def _drop_filestore(cfg, db_name: str) -> None:
595
+ """Remove the filestore directory belonging to a dropped database."""
596
+ from odoo_dev.config import read_data_dir
597
+
598
+ filestore = read_data_dir(cfg.config_file) / "filestore" / db_name
599
+ if not filestore.is_dir():
600
+ return
601
+ try:
602
+ shutil.rmtree(filestore)
603
+ except OSError as exc:
604
+ warning(f"Could not remove filestore {filestore}: {exc}")
585
605
 
586
606
 
587
607
  def _terminate_connections(db_name: str, db_config: dict[str, str]) -> None:
@@ -1,5 +1,6 @@
1
1
  """Setup commands for initializing Odoo development environment."""
2
2
 
3
+ import ast
3
4
  import importlib.resources
4
5
  import os
5
6
  import shutil
@@ -248,10 +249,85 @@ def setup_venv() -> None:
248
249
  [*venv_pip, "pytest", "pytest-odoo", "debugpy", "manifestoo", "coverage"]
249
250
  )
250
251
 
252
+ # Install the external Python dependencies our addons declare
253
+ _install_manifest_python_deps(cfg, venv_pip)
254
+
251
255
  success("\nVirtual environment setup complete!")
252
256
  success(f"To activate: source {cfg.venv_path}/bin/activate")
253
257
 
254
258
 
259
+ def _manifest_test_deps(addons_dir: Path) -> set[str]:
260
+ """Collect ``test_external_dependencies`` python packages from manifests.
261
+
262
+ This is an OCA convention rather than an Odoo core manifest key, so
263
+ manifestoo does not report it -- but the packages are needed to *run* the
264
+ tests of addons that declare it (e.g. pdfminer.six). Manifests that are not
265
+ plain literals are skipped rather than failing setup.
266
+ """
267
+ deps: set[str] = set()
268
+ if not addons_dir.is_dir():
269
+ return deps
270
+ for manifest_path in sorted(addons_dir.glob("*/__manifest__.py")):
271
+ try:
272
+ manifest = ast.literal_eval(manifest_path.read_text())
273
+ except (SyntaxError, ValueError):
274
+ warning(f"Could not parse {manifest_path}, skipping its test deps")
275
+ continue
276
+ if not isinstance(manifest, dict):
277
+ continue
278
+ test_deps = manifest.get("test_external_dependencies") or {}
279
+ deps.update(test_deps.get("python") or [])
280
+ return deps
281
+
282
+
283
+ def _manifest_runtime_deps(addons_dir: Path, venv_python: Path) -> set[str]:
284
+ """Collect ``external_dependencies`` python packages via manifestoo."""
285
+ if not addons_dir.is_dir():
286
+ return set()
287
+ result = subprocess.run(
288
+ [
289
+ str(venv_python),
290
+ "-m",
291
+ "manifestoo",
292
+ "--select-addons-dir",
293
+ str(addons_dir),
294
+ "list-external-dependencies",
295
+ "python",
296
+ ],
297
+ capture_output=True,
298
+ text=True,
299
+ )
300
+ if result.returncode != 0:
301
+ warning(f"manifestoo failed for {addons_dir}: {result.stderr.strip()}")
302
+ return set()
303
+ return {line.strip() for line in result.stdout.splitlines() if line.strip()}
304
+
305
+
306
+ def _install_manifest_python_deps(cfg, venv_pip: list[str]) -> None:
307
+ """Install the Python packages our addons declare in their manifests.
308
+
309
+ Covers both ``addons/`` (client-specific) and ``vendored/`` (shared), and
310
+ both runtime and test dependencies. Without this, a module whose tests need
311
+ an external package installs fine and then fails at test time.
312
+ """
313
+ venv_python = cfg.venv_path / "bin" / "python"
314
+ deps: set[str] = set()
315
+ for addons_dir in (cfg.addons_dir, cfg.vendored_dir):
316
+ deps |= _manifest_runtime_deps(addons_dir, venv_python)
317
+ deps |= _manifest_test_deps(addons_dir)
318
+
319
+ if not deps:
320
+ return
321
+
322
+ success(f"Installing addon manifest dependencies: {', '.join(sorted(deps))}")
323
+ result = subprocess.run([*venv_pip, *sorted(deps)])
324
+ if result.returncode != 0:
325
+ warning(
326
+ "Some addon dependencies failed to install; tests that need them "
327
+ "will fail until they are installed manually."
328
+ )
329
+
330
+
255
331
  def vscode(cfg=None) -> None:
256
332
  """Set up VSCode configuration for debugging."""
257
333
  if cfg is None:
@@ -334,6 +410,7 @@ def _setup_odoo_config(cfg, community_only: bool = False) -> None:
334
410
 
335
411
  if conf_file.exists():
336
412
  warning(f"Config file already exists at {conf_file}")
413
+ _ensure_data_dir_configured(cfg, conf_file)
337
414
  return
338
415
 
339
416
  success("Creating Odoo configuration file...")
@@ -366,6 +443,8 @@ def _setup_odoo_config(cfg, community_only: bool = False) -> None:
366
443
  db_host = os.getenv("DB_HOST", "")
367
444
  db_port = os.getenv("DB_PORT", "")
368
445
 
446
+ admin_passwd = os.getenv("ADMIN_PASSWD", "admin")
447
+
369
448
  db_lines = []
370
449
  if db_host:
371
450
  db_lines.append(f"db_host = {db_host}")
@@ -377,15 +456,55 @@ def _setup_odoo_config(cfg, community_only: bool = False) -> None:
377
456
  config_content = (
378
457
  "[options]\n"
379
458
  f"addons_path = {','.join(addons_paths)}\n"
380
- "admin_passwd = admin\n"
459
+ f"data_dir = {cfg.data_dir}\n"
460
+ f"admin_passwd = {admin_passwd}\n"
381
461
  f"{chr(10).join(db_lines)}\n"
382
462
  )
383
463
 
384
464
  conf_file.write_text(config_content)
385
465
  conf_file.chmod(0o600)
466
+ _gitignore_data_dir(cfg)
386
467
  success(f"Config file created at {conf_file}")
387
468
 
388
469
 
470
+ def _ensure_data_dir_configured(cfg, conf_file: Path) -> None:
471
+ """Add ``data_dir`` to an odoo.conf that predates it.
472
+
473
+ Existing projects already have a conf file, so the generator's early return
474
+ would otherwise leave them on the shared ``~/.local/share/Odoo``. Only a
475
+ missing key is filled in — an explicit data_dir is left alone.
476
+ """
477
+ content = conf_file.read_text()
478
+ if any(
479
+ line.strip().partition("=")[0].strip() == "data_dir"
480
+ for line in content.splitlines()
481
+ ):
482
+ return
483
+
484
+ lines = content.splitlines()
485
+ for i, line in enumerate(lines):
486
+ if line.strip() == "[options]":
487
+ lines.insert(i + 1, f"data_dir = {cfg.data_dir}")
488
+ break
489
+ else:
490
+ lines.append(f"data_dir = {cfg.data_dir}")
491
+ conf_file.write_text("\n".join(lines) + "\n")
492
+ _gitignore_data_dir(cfg)
493
+ success(f"Added data_dir = {cfg.data_dir} to {conf_file}")
494
+
495
+
496
+ def _gitignore_data_dir(cfg) -> None:
497
+ """Ensure the project-local data directory is git-ignored."""
498
+ gitignore = cfg.project_dir / ".gitignore"
499
+ entry = f"{cfg.data_dir.name}/"
500
+ lines = gitignore.read_text().splitlines() if gitignore.exists() else []
501
+ if entry in (line.strip() for line in lines):
502
+ return
503
+ lines.append(entry)
504
+ gitignore.write_text("\n".join(lines) + "\n")
505
+ success(f"Added {entry} to .gitignore")
506
+
507
+
389
508
  def _setup_docker_files(cfg, community_only: bool = False) -> None:
390
509
  """Copy Docker templates to .odoo-deploy directory."""
391
510
  success("Setting up Docker files...")
@@ -434,9 +553,11 @@ def _generate_docker_odoo_conf(
434
553
  if has_vendored:
435
554
  addons_paths.append("/opt/project/vendored")
436
555
 
556
+ admin_passwd = os.getenv("ADMIN_PASSWD", "admin")
557
+
437
558
  return f"""[options]
438
559
  addons_path = {",".join(addons_paths)}
439
- admin_passwd = admin
560
+ admin_passwd = {admin_passwd}
440
561
  db_host = db
441
562
  db_port = 5432
442
563
  db_user = odoo
@@ -59,6 +59,28 @@ def read_db_config(config_file: Path) -> "DbConfig":
59
59
  return db
60
60
 
61
61
 
62
+ # Odoo's own fallback when ``data_dir`` is absent from odoo.conf.
63
+ DEFAULT_ODOO_DATA_DIR = Path.home() / ".local" / "share" / "Odoo"
64
+
65
+
66
+ def read_data_dir(config_file: Path) -> Path:
67
+ """Read ``data_dir`` from an odoo.conf file.
68
+
69
+ Args:
70
+ config_file: Path to odoo.conf.
71
+
72
+ Returns:
73
+ The configured data directory, or Odoo's default when the file is
74
+ missing or does not set one.
75
+ """
76
+ if config_file.exists():
77
+ for line in config_file.read_text().splitlines():
78
+ key, sep, value = line.strip().partition("=")
79
+ if sep and key.strip() == "data_dir":
80
+ return Path(value.strip()).expanduser()
81
+ return DEFAULT_ODOO_DATA_DIR
82
+
83
+
62
84
  @dataclass
63
85
  class ProjectConfig:
64
86
  """Configuration for an Odoo development project."""
@@ -74,6 +96,18 @@ class ProjectConfig:
74
96
  """Path to the Python virtual environment."""
75
97
  return self.project_dir / ".venv"
76
98
 
99
+ @property
100
+ def data_dir(self) -> Path:
101
+ """Path to the project-local Odoo data directory.
102
+
103
+ Odoo defaults to a single shared ``~/.local/share/Odoo`` for every
104
+ project on the machine, which mixes filestores, sessions and the addons
105
+ cache across unrelated checkouts and leaves orphaned filestores behind
106
+ whenever a database is dropped without odoo-bin. Keeping it in-project
107
+ scopes all of that to the checkout it belongs to.
108
+ """
109
+ return self.project_dir / ".odoo-data"
110
+
77
111
  @property
78
112
  def config_file(self) -> Path:
79
113
  """Path to the local odoo.conf file."""
@@ -9,7 +9,9 @@ latter two and follows symlinks), which is what makes ``vendor check`` trustwort
9
9
  Python bytecode (``__pycache__/``, ``*.pyc``, ``*.pyo``) is excluded from the
10
10
  comparison: running or testing a vendored addon locally compiles it in place, and
11
11
  those artifacts are gitignored build output that is neither part of the pinned
12
- source nor present in a fresh CI checkout.
12
+ source nor present in a fresh CI checkout. A directory holding nothing but
13
+ bytecode (e.g. a ``tests/`` whose sources a pin bump removed) is skipped too: git
14
+ does not track empty directories, so CI never sees it.
13
15
  """
14
16
 
15
17
  from __future__ import annotations
@@ -69,12 +71,20 @@ _BYTECODE_DIRS = {"__pycache__"}
69
71
  _BYTECODE_SUFFIXES = (".pyc", ".pyo")
70
72
 
71
73
 
74
+ def is_bytecode(rel: str) -> bool:
75
+ """True if ``rel`` is python bytecode or lies inside a ``__pycache__`` dir."""
76
+ parts = Path(rel).parts
77
+ return rel.endswith(_BYTECODE_SUFFIXES) or any(
78
+ p in _BYTECODE_DIRS for p in parts
79
+ )
80
+
81
+
72
82
  def _entries(root: Path) -> dict:
73
83
  """Map each path relative to ``root`` to a comparable fingerprint.
74
84
 
75
85
  - symlink -> ("link", target) (never dereferenced)
76
86
  - file -> ("file", exec_bit, sha1)
77
- - dir -> ("dir",)
87
+ - dir -> ("dir",) (only if it holds a file or link)
78
88
 
79
89
  Python bytecode is skipped entirely (see the module docstring).
80
90
  """
@@ -104,7 +114,14 @@ def _entries(root: Path) -> dict:
104
114
  else:
105
115
  exec_bit = 1 if os.stat(p).st_mode & 0o100 else 0
106
116
  out[rel] = ("file", exec_bit, _sha1(p))
107
- return out
117
+ # Like git, a directory exists only through its contents.
118
+ occupied = {
119
+ str(parent)
120
+ for rel, v in out.items()
121
+ if v[0] != "dir"
122
+ for parent in Path(rel).parents
123
+ }
124
+ return {rel: v for rel, v in out.items() if v[0] != "dir" or rel in occupied}
108
125
 
109
126
 
110
127
  def _sha1(path: Path) -> str:
@@ -6,7 +6,8 @@ Assertions (any failure => non-empty problem list => CI red):
6
6
  3. moved-tag tripwire (when the entry carries a ``version`` tag);
7
7
  4. external python deps of vendored manifests are present in ``requirements.txt``;
8
8
  5. no addon name in both ``addons/`` and ``vendored/`` (double-load);
9
- 6. no file under ``vendored/`` is gitignored (it would be dropped at commit);
9
+ 6. no file under ``vendored/`` is gitignored (it would be dropped at commit),
10
+ and no python bytecode under it is tracked by git (it is not in the pin);
10
11
  7. (``allow_hybrid=False`` only) no ``addons/`` symlink still points into a
11
12
  ``.repos/`` submodule — i.e. the repo is fully vendored, not a hybrid.
12
13
  The gate writes nothing.
@@ -23,7 +24,7 @@ from pathlib import Path
23
24
  from typing import Optional
24
25
 
25
26
  from odoo_dev.vendor.lock import Lockfile
26
- from odoo_dev.vendor.materialize import extract_subtree, tree_diff
27
+ from odoo_dev.vendor.materialize import extract_subtree, is_bytecode, tree_diff
27
28
  from odoo_dev.vendor.sources import get_source_at, tag_resolves_to
28
29
 
29
30
  _MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py")
@@ -124,6 +125,9 @@ def gitignored_under_vendored(project_dir: Path) -> list:
124
125
  ``check-ignore`` (not a tracked-vs-on-disk comparison) is the right probe: it
125
126
  reports exactly the paths git would drop, and consults the index, so neither a
126
127
  not-yet-staged ``vendor add`` nor a deliberate ``git add -f`` is a false alarm.
128
+
129
+ Python bytecode is not probed: it is never part of a pin, so ignoring it is
130
+ correct (and :func:`tracked_bytecode_under_vendored` catches the opposite).
127
131
  """
128
132
  project_dir = Path(project_dir)
129
133
  vendored = project_dir / "vendored"
@@ -132,7 +136,8 @@ def gitignored_under_vendored(project_dir: Path) -> list:
132
136
  rels = [
133
137
  str(p.relative_to(project_dir))
134
138
  for p in sorted(vendored.rglob("*"))
135
- if p.is_symlink() or p.is_file()
139
+ if (p.is_symlink() or p.is_file())
140
+ and not is_bytecode(str(p.relative_to(vendored)))
136
141
  ]
137
142
  if not rels:
138
143
  return []
@@ -149,6 +154,39 @@ def gitignored_under_vendored(project_dir: Path) -> list:
149
154
  return [line for line in res.stdout.splitlines() if line.strip()]
150
155
 
151
156
 
157
+ def tracked_bytecode_under_vendored(project_dir: Path) -> list:
158
+ """Python bytecode under ``vendored/`` that git tracks (index or HEAD).
159
+
160
+ :func:`tree_diff` skips bytecode so local test runs don't fail the gate, which
161
+ would also hide a ``.pyc`` that was committed by mistake. Untracked or
162
+ gitignored bytecode is fine; tracked bytecode ships a tree the pin does not
163
+ describe.
164
+ """
165
+ project_dir = Path(project_dir)
166
+ if not (project_dir / "vendored").is_dir() or not (project_dir / ".git").exists():
167
+ return []
168
+ res = subprocess.run(
169
+ ["git", "-C", str(project_dir), "ls-files", "-z", "--", "vendored"],
170
+ capture_output=True,
171
+ text=True,
172
+ )
173
+ if res.returncode != 0:
174
+ return []
175
+ return [
176
+ rel
177
+ for rel in res.stdout.split("\0")
178
+ if rel and is_bytecode(rel[len("vendored/"):])
179
+ ]
180
+
181
+
182
+ def _has_source(addon_dir: Path) -> bool:
183
+ """True if ``addon_dir`` holds anything besides python bytecode."""
184
+ return any(
185
+ (p.is_file() or p.is_symlink()) and not is_bytecode(str(p.relative_to(addon_dir)))
186
+ for p in addon_dir.rglob("*")
187
+ )
188
+
189
+
152
190
  def verify(
153
191
  project_dir: Path,
154
192
  lock: Lockfile,
@@ -167,7 +205,7 @@ def verify(
167
205
  problems: list = []
168
206
 
169
207
  vendored_dirs = (
170
- {p.name for p in vendored.iterdir() if p.is_dir()}
208
+ {p.name for p in vendored.iterdir() if p.is_dir() and _has_source(p)}
171
209
  if vendored.exists()
172
210
  else set()
173
211
  )
@@ -227,6 +265,13 @@ def verify(
227
265
  f"(add a '!vendored/**' negation, keep it last)"
228
266
  )
229
267
 
268
+ for rel in tracked_bytecode_under_vendored(project_dir):
269
+ problems.append(
270
+ f"{rel}: python bytecode under vendored/ is tracked by git — it is not "
271
+ f"part of the pin (git rm --cached it; ignore '**/__pycache__/' after "
272
+ f"the '!vendored/**' negation)"
273
+ )
274
+
230
275
  if not allow_hybrid:
231
276
  for name in hybrid_submodule_addons(project_dir):
232
277
  problems.append(
@@ -0,0 +1,49 @@
1
+ """Tests for filestore cleanup on the dropdb fallback path."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from odoo_dev.commands.run import _drop_filestore
8
+ from odoo_dev.config import ProjectConfig
9
+
10
+
11
+ @pytest.fixture
12
+ def cfg(tmp_path: Path) -> ProjectConfig:
13
+ (tmp_path / "conf").mkdir()
14
+ return ProjectConfig(
15
+ project_dir=tmp_path,
16
+ script_dir=tmp_path / ".odoo-deploy",
17
+ odoo_version="19.0",
18
+ python_version="3.12",
19
+ project_name="proj",
20
+ )
21
+
22
+
23
+ class TestDropFilestore:
24
+ """The dropdb fallback must clean up the filestore odoo-bin would have."""
25
+
26
+ def test_removes_filestore_for_dropped_db(self, cfg):
27
+ cfg.config_file.write_text(f"[options]\ndata_dir = {cfg.data_dir}\n")
28
+ filestore = cfg.data_dir / "filestore" / "test_123"
29
+ filestore.mkdir(parents=True)
30
+ (filestore / "blob").write_text("x")
31
+
32
+ _drop_filestore(cfg, "test_123")
33
+
34
+ assert not filestore.exists()
35
+
36
+ def test_leaves_other_filestores_alone(self, cfg):
37
+ cfg.config_file.write_text(f"[options]\ndata_dir = {cfg.data_dir}\n")
38
+ keep = cfg.data_dir / "filestore" / "other_db"
39
+ keep.mkdir(parents=True)
40
+
41
+ _drop_filestore(cfg, "test_123")
42
+
43
+ assert keep.exists()
44
+
45
+ def test_missing_filestore_is_not_an_error(self, cfg):
46
+ cfg.config_file.write_text(f"[options]\ndata_dir = {cfg.data_dir}\n")
47
+
48
+ _drop_filestore(cfg, "test_123")
49
+
@@ -90,12 +90,21 @@ def cleanup_fixture_env():
90
90
  "conf",
91
91
  ".vscode",
92
92
  ".odoo-deploy",
93
+ ".odoo-data",
93
94
  ]
94
95
  for dirname in created_dirs:
95
96
  dir_path = FIXTURE_DIR / dirname
96
97
  if dir_path.exists():
97
98
  shutil.rmtree(dir_path, ignore_errors=True)
98
99
 
100
+ # setup adds .odoo-data/ to the project's .gitignore; the fixture is a
101
+ # tracked submodule, so restore it rather than leaving the tree dirty.
102
+ subprocess.run(
103
+ ["git", "checkout", "--", ".gitignore"],
104
+ cwd=FIXTURE_DIR,
105
+ capture_output=True,
106
+ )
107
+
99
108
 
100
109
  @pytest.fixture(scope="session")
101
110
  def setup_fixture_env():
@@ -179,7 +179,14 @@ class TestLocalOdooConf:
179
179
  cfg = _make_cfg(tmp_path)
180
180
  (tmp_path / "conf").mkdir()
181
181
  _setup_odoo_config(cfg)
182
- assert "vendored" not in cfg.config_file.read_text()
182
+ # Scoped to addons_path: data_dir also embeds project_dir, whose tmp
183
+ # path can itself contain "vendored".
184
+ addons_path = next(
185
+ line
186
+ for line in cfg.config_file.read_text().splitlines()
187
+ if line.startswith("addons_path")
188
+ )
189
+ assert "vendored" not in addons_path
183
190
 
184
191
  def test_local_conf_includes_vendored_when_present(self, tmp_path: Path):
185
192
  from odoo_dev.commands.setup import _setup_odoo_config
@@ -0,0 +1,179 @@
1
+ """Tests for the project-local data_dir and manifest dependency install."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+
8
+ from odoo_dev.commands.setup import (
9
+ _ensure_data_dir_configured,
10
+ _gitignore_data_dir,
11
+ _install_manifest_python_deps,
12
+ _manifest_test_deps,
13
+ _setup_odoo_config,
14
+ )
15
+ from odoo_dev.config import DEFAULT_ODOO_DATA_DIR, ProjectConfig, read_data_dir
16
+
17
+
18
+ @pytest.fixture
19
+ def cfg(tmp_path: Path) -> ProjectConfig:
20
+ (tmp_path / "conf").mkdir()
21
+ return ProjectConfig(
22
+ project_dir=tmp_path,
23
+ script_dir=tmp_path / ".odoo-deploy",
24
+ odoo_version="19.0",
25
+ python_version="3.12",
26
+ project_name="proj",
27
+ )
28
+
29
+
30
+ def _write_manifest(addons_dir: Path, name: str, body: str) -> None:
31
+ mod = addons_dir / name
32
+ mod.mkdir(parents=True)
33
+ (mod / "__manifest__.py").write_text(body)
34
+
35
+
36
+ class TestDataDirConfig:
37
+ def test_generated_config_sets_project_local_data_dir(self, cfg):
38
+ _setup_odoo_config(cfg)
39
+
40
+ content = cfg.config_file.read_text()
41
+ assert f"data_dir = {cfg.project_dir / '.odoo-data'}" in content
42
+
43
+ def test_generated_config_gitignores_data_dir(self, cfg):
44
+ _setup_odoo_config(cfg)
45
+
46
+ assert ".odoo-data/" in (cfg.project_dir / ".gitignore").read_text()
47
+
48
+ def test_existing_config_gains_data_dir(self, cfg):
49
+ cfg.config_file.write_text("[options]\nadmin_passwd = admin\n")
50
+
51
+ _setup_odoo_config(cfg)
52
+
53
+ lines = cfg.config_file.read_text().splitlines()
54
+ assert lines[0] == "[options]"
55
+ assert lines[1] == f"data_dir = {cfg.data_dir}"
56
+ assert "admin_passwd = admin" in lines
57
+
58
+ def test_existing_data_dir_is_not_overwritten(self, cfg):
59
+ cfg.config_file.write_text("[options]\ndata_dir = /srv/odoo-data\n")
60
+
61
+ _ensure_data_dir_configured(cfg, cfg.config_file)
62
+
63
+ assert "/srv/odoo-data" in cfg.config_file.read_text()
64
+ assert str(cfg.data_dir) not in cfg.config_file.read_text()
65
+
66
+ def test_gitignore_entry_is_not_duplicated(self, cfg):
67
+ (cfg.project_dir / ".gitignore").write_text(".odoo-data/\n.venv/\n")
68
+
69
+ _gitignore_data_dir(cfg)
70
+
71
+ content = (cfg.project_dir / ".gitignore").read_text()
72
+ assert content.count(".odoo-data/") == 1
73
+
74
+
75
+ class TestReadDataDir:
76
+ def test_reads_configured_value(self, tmp_path: Path):
77
+ conf = tmp_path / "odoo.conf"
78
+ conf.write_text("[options]\ndata_dir = /srv/odoo-data\n")
79
+
80
+ assert read_data_dir(conf) == Path("/srv/odoo-data")
81
+
82
+ def test_falls_back_to_odoo_default(self, tmp_path: Path):
83
+ conf = tmp_path / "odoo.conf"
84
+ conf.write_text("[options]\nadmin_passwd = admin\n")
85
+
86
+ assert read_data_dir(conf) == DEFAULT_ODOO_DATA_DIR
87
+
88
+ def test_missing_file_falls_back_to_odoo_default(self, tmp_path: Path):
89
+ assert read_data_dir(tmp_path / "nope.conf") == DEFAULT_ODOO_DATA_DIR
90
+
91
+
92
+ class TestManifestTestDeps:
93
+ def test_collects_test_external_dependencies(self, tmp_path: Path):
94
+ _write_manifest(
95
+ tmp_path,
96
+ "mod_a",
97
+ "{'name': 'A', 'test_external_dependencies': {'python': ['pdfminer.six']}}",
98
+ )
99
+
100
+ assert _manifest_test_deps(tmp_path) == {"pdfminer.six"}
101
+
102
+ def test_ignores_runtime_dependencies(self, tmp_path: Path):
103
+ _write_manifest(
104
+ tmp_path,
105
+ "mod_a",
106
+ "{'name': 'A', 'external_dependencies': {'python': ['openpyxl']}}",
107
+ )
108
+
109
+ assert _manifest_test_deps(tmp_path) == set()
110
+
111
+ def test_skips_unparseable_manifest(self, tmp_path: Path):
112
+ _write_manifest(tmp_path, "mod_a", "{'name': 'A', 'x': undefined_name}")
113
+ _write_manifest(
114
+ tmp_path,
115
+ "mod_b",
116
+ "{'name': 'B', 'test_external_dependencies': {'python': ['freezegun']}}",
117
+ )
118
+
119
+ assert _manifest_test_deps(tmp_path) == {"freezegun"}
120
+
121
+ def test_missing_dir_is_empty(self, tmp_path: Path):
122
+ assert _manifest_test_deps(tmp_path / "nope") == set()
123
+
124
+
125
+ class TestInstallManifestPythonDeps:
126
+ def test_installs_runtime_and_test_deps_from_both_dirs(self, cfg):
127
+ _write_manifest(
128
+ cfg.addons_dir,
129
+ "mod_a",
130
+ "{'name': 'A', 'test_external_dependencies': {'python': ['freezegun']}}",
131
+ )
132
+ _write_manifest(
133
+ cfg.vendored_dir,
134
+ "mod_b",
135
+ "{'name': 'B', 'test_external_dependencies': {'python': ['pdfminer.six']}}",
136
+ )
137
+
138
+ with patch("odoo_dev.commands.setup._manifest_runtime_deps") as runtime:
139
+ runtime.side_effect = [{"openpyxl"}, {"xlsxwriter"}]
140
+ with patch("odoo_dev.commands.setup.subprocess.run") as run:
141
+ run.return_value.returncode = 0
142
+ _install_manifest_python_deps(cfg, ["uv", "pip", "install"])
143
+
144
+ installed = set(run.call_args[0][0][3:])
145
+ assert installed == {"openpyxl", "xlsxwriter", "freezegun", "pdfminer.six"}
146
+
147
+ def test_no_deps_installs_nothing(self, cfg):
148
+ cfg.addons_dir.mkdir()
149
+
150
+ with patch("odoo_dev.commands.setup._manifest_runtime_deps", return_value=set()):
151
+ with patch("odoo_dev.commands.setup.subprocess.run") as run:
152
+ _install_manifest_python_deps(cfg, ["uv", "pip", "install"])
153
+
154
+ run.assert_not_called()
155
+
156
+
157
+ class TestAdminPasswd:
158
+ """admin_passwd follows the same .env pattern as the DB settings."""
159
+
160
+ def test_local_conf_defaults_to_admin(self, cfg, monkeypatch):
161
+ monkeypatch.delenv("ADMIN_PASSWD", raising=False)
162
+
163
+ _setup_odoo_config(cfg)
164
+
165
+ assert "admin_passwd = admin\n" in cfg.config_file.read_text()
166
+
167
+ def test_local_conf_honours_env(self, cfg, monkeypatch):
168
+ monkeypatch.setenv("ADMIN_PASSWD", "s3cret")
169
+
170
+ _setup_odoo_config(cfg)
171
+
172
+ assert "admin_passwd = s3cret\n" in cfg.config_file.read_text()
173
+
174
+ def test_docker_conf_honours_env(self, monkeypatch):
175
+ from odoo_dev.commands.setup import _generate_docker_odoo_conf
176
+
177
+ monkeypatch.setenv("ADMIN_PASSWD", "s3cret")
178
+
179
+ assert "admin_passwd = s3cret\n" in _generate_docker_odoo_conf()
@@ -243,3 +243,63 @@ def test_verify_strict_ignores_non_repos_symlink(tmp_path):
243
243
  (proj / "addons").mkdir(exist_ok=True)
244
244
  (proj / "addons" / "external").symlink_to(Path("../elsewhere"))
245
245
  assert verify(proj, lock, cache_dir=tmp_path / "cache", allow_hybrid=False) == []
246
+
247
+
248
+ # --- python bytecode left behind by running/testing vendored addons -----------
249
+
250
+
251
+ def _compile_in_place(proj: Path) -> list:
252
+ """Simulate ``odoo-dev test``: drop bytecode beside the vendored sources.
253
+
254
+ Includes the two shapes that outlive a pin bump: a subdir whose sources are
255
+ gone but whose ``__pycache__`` remains, and a whole ex-addon left as nothing
256
+ but bytecode (git does not track the empty dirs, so CI never sees either).
257
+ """
258
+ pycs = [
259
+ "vendored/shared_addon/__pycache__/models.cpython-312.pyc",
260
+ "vendored/shared_addon/tests/__pycache__/test_x.cpython-312.pyc",
261
+ "vendored/removed_addon/__pycache__/__init__.cpython-312.pyc",
262
+ ]
263
+ for rel in pycs:
264
+ p = proj / rel
265
+ p.parent.mkdir(parents=True, exist_ok=True)
266
+ p.write_bytes(b"\x00compiled")
267
+ return pycs
268
+
269
+
270
+ def test_verify_green_with_untracked_bytecode_outside_git(tmp_path):
271
+ proj, lock = _sync_clean(tmp_path)
272
+ _compile_in_place(proj)
273
+ assert verify(proj, lock, cache_dir=tmp_path / "cache") == []
274
+
275
+
276
+ def test_verify_green_with_gitignored_bytecode(tmp_path):
277
+ """Regression: ignoring ``__pycache__`` after ``!vendored/**`` is correct
278
+ hygiene, but the gitignore probe reported every local .pyc as a file git
279
+ would drop, failing the gate after any local test run.
280
+ """
281
+ repo, sha = _source_repo(tmp_path)
282
+ proj, lock = _git_project(
283
+ tmp_path,
284
+ LockEntry("shared_addon", str(repo), sha),
285
+ "!vendored/**\n**/__pycache__/\n",
286
+ )
287
+ sync_addons(proj, lock, cache_dir=tmp_path / "cache")
288
+ _compile_in_place(proj)
289
+ assert verify(proj, lock, cache_dir=tmp_path / "cache") == []
290
+
291
+
292
+ def test_verify_red_when_bytecode_is_tracked(tmp_path):
293
+ """Bytecode committed under vendored/ is not in the pin; it must fail."""
294
+ repo, sha = _source_repo(tmp_path)
295
+ proj, lock = _git_project(
296
+ tmp_path,
297
+ LockEntry("shared_addon", str(repo), sha),
298
+ "!vendored/**\n**/__pycache__/\n",
299
+ )
300
+ sync_addons(proj, lock, cache_dir=tmp_path / "cache")
301
+ pycs = _compile_in_place(proj)
302
+ _git(proj, "add", "-f", pycs[0])
303
+ problems = verify(proj, lock, cache_dir=tmp_path / "cache")
304
+ assert len(problems) == 1
305
+ assert pycs[0] in problems[0] and "tracked" in problems[0]
@@ -96,7 +96,7 @@ wheels = [
96
96
 
97
97
  [[package]]
98
98
  name = "odoo-dev"
99
- version = "1.2.3"
99
+ version = "1.4.0"
100
100
  source = { editable = "." }
101
101
  dependencies = [
102
102
  { name = "pyyaml" },
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.4.0"
3
- }
@@ -1,3 +0,0 @@
1
- """Odoo Development Environment Helper."""
2
-
3
- __version__ = "1.4.0" # x-release-please-version
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes