2026-03-30 14:46:56 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
Utility module for reading and writing .erplibre-state.json.
|
|
|
|
|
|
|
|
|
|
This file tracks what is installed in the current ERPLibre workspace:
|
|
|
|
|
- Which Odoo versions are installed, and with which options (extra, etc.)
|
|
|
|
|
- Whether the mobile project is active
|
|
|
|
|
- The currently active Odoo version
|
[REM] install: drop Ubuntu 20.04 and 22.04
Two blockers on 20.04, neither worth carrying. "TypeError: unsupported
operand type(s) for |" fired before the install even started:
update_env_version.py imports erplibre_state as the very first thing
"make install_os" does, hence before pyenv, on the system Python 3.8,
where a "str | None" annotation is evaluated at import. And cryptography
now demands OpenSSL 3, while focal ships 1.1.1.
Annotations are therefore deferred, a contract the bootstrap file already
stated in a comment and now holds across script/version and
script/install. But the releases themselves go: pikepdf requires
qpdf >= 12.2, compiled in C++20, when focal ships GCC 9.
--- FR ---
Deux blocages sur 20.04, aucun ne méritant d'être porté. « TypeError:
unsupported operand type(s) for | » se déclenchait avant même le début de
l'installation : update_env_version.py importe erplibre_state en tout
premier lieu de « make install_os », donc avant pyenv, sur le Python 3.8
du système, où une annotation « str | None » est évaluée à l'import. Et
cryptography exige désormais OpenSSL 3, quand focal livre 1.1.1.
Les annotations sont donc différées, contrat que le fichier d'amorçage
énonçait déjà en commentaire et qui tient maintenant sur script/version et
script/install. Mais les versions elles-mêmes partent : pikepdf réclame
qpdf >= 12.2, compilé en C++20, quand focal livre GCC 9.
Assisted-by: Claude Opus 5
2026-08-11 08:03:32 -04:00
|
|
|
|
|
|
|
|
`update_env_version.py` imports this module as the FIRST thing `make
|
|
|
|
|
install_os` runs, donc avant pyenv : il s'exécute sur le Python DU SYSTÈME,
|
|
|
|
|
3.8 sur Ubuntu 20.04. Les annotations doivent rester différées — sans quoi
|
|
|
|
|
« str | None » (PEP 604) ou « list[str] » (PEP 585) sont évaluées à
|
|
|
|
|
l'import et lèvent un TypeError avant même que l'installation commence.
|
2026-03-30 14:46:56 -04:00
|
|
|
"""
|
|
|
|
|
|
[REM] install: drop Ubuntu 20.04 and 22.04
Two blockers on 20.04, neither worth carrying. "TypeError: unsupported
operand type(s) for |" fired before the install even started:
update_env_version.py imports erplibre_state as the very first thing
"make install_os" does, hence before pyenv, on the system Python 3.8,
where a "str | None" annotation is evaluated at import. And cryptography
now demands OpenSSL 3, while focal ships 1.1.1.
Annotations are therefore deferred, a contract the bootstrap file already
stated in a comment and now holds across script/version and
script/install. But the releases themselves go: pikepdf requires
qpdf >= 12.2, compiled in C++20, when focal ships GCC 9.
--- FR ---
Deux blocages sur 20.04, aucun ne méritant d'être porté. « TypeError:
unsupported operand type(s) for | » se déclenchait avant même le début de
l'installation : update_env_version.py importe erplibre_state en tout
premier lieu de « make install_os », donc avant pyenv, sur le Python 3.8
du système, où une annotation « str | None » est évaluée à l'import. Et
cryptography exige désormais OpenSSL 3, quand focal livre 1.1.1.
Les annotations sont donc différées, contrat que le fichier d'amorçage
énonçait déjà en commentaire et qui tient maintenant sur script/version et
script/install. Mais les versions elles-mêmes partent : pikepdf réclame
qpdf >= 12.2, compilé en C++20, quand focal livre GCC 9.
Assisted-by: Claude Opus 5
2026-08-11 08:03:32 -04:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-03-30 14:46:56 -04:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
STATE_FILE = ".erplibre-state.json"
|
|
|
|
|
|
|
|
|
|
_EMPTY_VERSION_ENTRY = {
|
|
|
|
|
"installed": False,
|
|
|
|
|
"extra": False,
|
|
|
|
|
"python": None,
|
|
|
|
|
"poetry": None,
|
|
|
|
|
"installed_at": None,
|
|
|
|
|
"switched_at": None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_EMPTY_STATE = {
|
|
|
|
|
"current_odoo_version": None,
|
|
|
|
|
"mobile": {
|
|
|
|
|
"active": False,
|
|
|
|
|
"installed_at": None,
|
|
|
|
|
},
|
|
|
|
|
"odoo_versions": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_state() -> dict:
|
|
|
|
|
"""Return the current state, or an empty state if the file does not exist."""
|
|
|
|
|
if not os.path.isfile(STATE_FILE):
|
|
|
|
|
return _deep_copy(_EMPTY_STATE)
|
|
|
|
|
try:
|
|
|
|
|
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
return json.load(f)
|
|
|
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
|
|
|
_logger.warning(f"Cannot read {STATE_FILE}: {e}. Using empty state.")
|
|
|
|
|
return _deep_copy(_EMPTY_STATE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_state(state: dict) -> None:
|
|
|
|
|
"""Persist state to .erplibre-state.json."""
|
|
|
|
|
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
|
|
|
|
json.dump(state, f, indent=4, ensure_ascii=False)
|
|
|
|
|
f.write("\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_version_installed(
|
|
|
|
|
odoo_version: str,
|
|
|
|
|
extra: bool = False,
|
|
|
|
|
python: str = None,
|
|
|
|
|
poetry: str = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Record that an Odoo version has been installed (or reinstalled)."""
|
|
|
|
|
state = read_state()
|
|
|
|
|
entry = state["odoo_versions"].get(odoo_version, _deep_copy(_EMPTY_VERSION_ENTRY))
|
|
|
|
|
entry["installed"] = True
|
|
|
|
|
entry["extra"] = extra
|
|
|
|
|
if python:
|
|
|
|
|
entry["python"] = python
|
|
|
|
|
if poetry:
|
|
|
|
|
entry["poetry"] = poetry
|
|
|
|
|
entry["installed_at"] = str(date.today())
|
|
|
|
|
state["odoo_versions"][odoo_version] = entry
|
|
|
|
|
state["current_odoo_version"] = odoo_version
|
|
|
|
|
write_state(state)
|
|
|
|
|
_logger.info(
|
|
|
|
|
f"State updated: odoo {odoo_version} installed"
|
|
|
|
|
f" (extra={extra}, python={python}, poetry={poetry})"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_version_switched(odoo_version: str) -> None:
|
|
|
|
|
"""Record that the workspace was switched to an Odoo version."""
|
|
|
|
|
state = read_state()
|
|
|
|
|
entry = state["odoo_versions"].get(odoo_version, _deep_copy(_EMPTY_VERSION_ENTRY))
|
|
|
|
|
entry["switched_at"] = str(date.today())
|
|
|
|
|
state["odoo_versions"][odoo_version] = entry
|
|
|
|
|
state["current_odoo_version"] = odoo_version
|
|
|
|
|
write_state(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_mobile_active(active: bool) -> None:
|
|
|
|
|
"""Record whether the mobile project is currently synced."""
|
|
|
|
|
state = read_state()
|
|
|
|
|
state["mobile"]["active"] = active
|
|
|
|
|
if active:
|
|
|
|
|
state["mobile"]["installed_at"] = str(date.today())
|
|
|
|
|
write_state(state)
|
|
|
|
|
_logger.info(f"State updated: mobile active={active}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_version_extra(odoo_version: str) -> bool:
|
|
|
|
|
"""Return True if the given Odoo version was installed with extra modules."""
|
|
|
|
|
state = read_state()
|
|
|
|
|
entry = state["odoo_versions"].get(odoo_version)
|
|
|
|
|
if entry is None:
|
|
|
|
|
return False
|
|
|
|
|
return bool(entry.get("extra", False))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_version_installed(odoo_version: str) -> bool:
|
|
|
|
|
"""Return True if the given Odoo version has an installation record."""
|
|
|
|
|
state = read_state()
|
|
|
|
|
entry = state["odoo_versions"].get(odoo_version)
|
|
|
|
|
if entry is None:
|
|
|
|
|
return False
|
|
|
|
|
return bool(entry.get("installed", False))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_version() -> str | None:
|
|
|
|
|
"""Return the currently active Odoo version, or None."""
|
|
|
|
|
return read_state().get("current_odoo_version")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_mobile_active() -> bool:
|
|
|
|
|
"""Return True if mobile is recorded as active."""
|
|
|
|
|
return bool(read_state().get("mobile", {}).get("active", False))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def print_state() -> None:
|
|
|
|
|
"""Log a human-readable summary of the current state."""
|
|
|
|
|
state = read_state()
|
|
|
|
|
current = state.get("current_odoo_version") or "unknown"
|
|
|
|
|
mobile = state.get("mobile", {})
|
|
|
|
|
mobile_status = "active" if mobile.get("active") else "inactive"
|
|
|
|
|
|
|
|
|
|
_logger.info(f"Current Odoo version : {current}")
|
|
|
|
|
_logger.info(f"Mobile context : {mobile_status}")
|
|
|
|
|
|
|
|
|
|
versions = state.get("odoo_versions", {})
|
|
|
|
|
if versions:
|
|
|
|
|
installed = [
|
|
|
|
|
f"{v}{' +extra' if d.get('extra') else ''}"
|
|
|
|
|
for v, d in sorted(versions.items())
|
|
|
|
|
if d.get("installed")
|
|
|
|
|
]
|
|
|
|
|
if installed:
|
|
|
|
|
_logger.info(f"Installed versions : {', '.join(installed)}")
|
|
|
|
|
else:
|
|
|
|
|
_logger.info("Installed versions : none recorded")
|
|
|
|
|
else:
|
|
|
|
|
_logger.info("Installed versions : none recorded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _deep_copy(d: dict) -> dict:
|
|
|
|
|
"""Simple deep copy for plain dicts/lists (no external deps)."""
|
|
|
|
|
return json.loads(json.dumps(d))
|