erplibre/script/code_generator/new_project.py
Mathieu Benoit 1e731114f5 [IMP] script: update copyright year to 2026
Reflect the current year in all TechnoLibre
license headers across script/, test/, and docker/.

Generated by Claude Code 2.1.74 model claude-sonnet-4-6

Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-03-11 23:16:05 -04:00

608 lines
20 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import argparse
import configparser
import json
import logging
import os
import sys
import tempfile
import uuid
from git import Repo
from git.exc import InvalidGitRepositoryError, NoSuchPathError
logging.basicConfig(
format=(
"%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]"
" %(message)s"
),
datefmt="%Y-%m-%d:%H:%M:%S",
level=logging.INFO,
)
_logger = logging.getLogger(__name__)
filename_odoo_version = ".odoo-version"
if not os.path.isfile(filename_odoo_version):
_logger.error(f"Missing file {filename_odoo_version}")
sys.exit(1)
with open(filename_odoo_version, "r") as f:
odoo_version = f.readline()
CODE_GENERATOR_DIRECTORY = (
f"./odoo{odoo_version}/addons/TechnoLibre_odoo-code-generator-template/"
)
CODE_GENERATOR_DEMO_NAME = "code_generator_demo"
KEY_REPLACE_CODE_GENERATOR_DEMO = 'MODULE_NAME = "%s"'
class Struct:
def __init__(self, **entries):
self.__dict__.update(entries)
# TODO execute create_code_generator_from_existing_module.sh with force option
# TODO open web interface on right database already selected locally with make run
def get_config():
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description="""\
Create new project for a single module with code generator suite.
""",
epilog="""\
""",
)
parser.add_argument(
"-d",
"--directory",
required=True,
help="Directory of the module, need to be a git root directory.",
)
parser.add_argument(
"-m",
"--module",
required=True,
help="Module name to create",
)
parser.add_argument(
"--odoo_config",
default="./config.conf",
help="Odoo config path",
)
parser.add_argument(
"--config",
help="""Configuration to create models with fields and type. JSON style.
Example : "{\"model\":[{\"name\":\"a\",\"fields\":[{\"name\":\"a\",\"type\":\"char\"}]}]}" """,
)
parser.add_argument(
"--directory_code_generator",
help="The directory of the code_generator to use.",
)
parser.add_argument(
"--coverage",
action="store_true",
help="Execute coverage file.",
)
parser.add_argument(
"--code_generator_name",
help="The name of the code_generator to use.",
)
parser.add_argument(
"--directory_template_name",
help="The directory of the template to use.",
)
parser.add_argument(
"--template_name",
help="The name of the template to use.",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
help="Force override directory and module.",
)
parser.add_argument(
"--keep_bd_alive",
action="store_true",
help="By default, the bd is cleaned after a run.",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug output",
)
args = parser.parse_args()
return args
class ProjectManagement:
def __init__(
self,
module_name,
module_directory,
cg_name="",
cg_directory="",
template_name="",
template_directory="",
force=False,
keep_bd_alive=False,
coverage=False,
config="",
odoo_config="./config.conf",
):
self.force = force
self._coverage = coverage
self.keep_bd_alive = keep_bd_alive
self.msg_error = ""
self.has_config_update = False
self.odoo_config = odoo_config
# Replace addons/ by odoo12.0/addons/
module_directory = module_directory.replace(
"addons/", f"odoo{odoo_version}/addons/"
)
self.module_directory = module_directory
if not os.path.exists(self.module_directory):
self.msg_error = (
f"Path directory '{self.module_directory}' not exist. You"
f" actual path is: '{os.getcwd()}'."
)
_logger.error(self.msg_error)
return
self.cg_directory = cg_directory if cg_directory else module_directory
if not os.path.exists(self.cg_directory):
self.msg_error = (
f"Path cg directory '{self.cg_directory}' not exist. You"
f" actual path is: '{os.getcwd()}'."
)
_logger.error(self.msg_error)
return
self.template_directory = (
template_directory if template_directory else module_directory
)
if not os.path.exists(self.template_directory):
self.msg_error = (
f"Path template directory '{self.template_directory}' not"
" exist."
)
_logger.error(self.msg_error)
return
if not module_name:
self.msg_error = "Module name is missing."
_logger.error(self.msg_error)
return
# Get module name
self.module_name = module_name
# Get code_generator name
self.cg_name = self._generate_cg_name(default=cg_name)
# Get template name
self.template_name = self._generate_template_name(
default=template_name
)
self._parse_config(config)
def _parse_config(self, config):
if not config:
self.config = {}
self.config_lst_model = []
else:
self.config = json.loads(config)
self.config_lst_model = self.config.get("model")
def _generate_cg_name(self, default=""):
if default:
return default
return f"code_generator_{self.module_name}"
def _generate_template_name(self, default=""):
if default:
return default
return f"code_generator_template_{self.module_name}"
def search_and_replace_file(self, filepath, lst_search_and_replace):
"""
lst_search_and_replace is a list of tuple, first item is search, second is replace
"""
with open(filepath, "r") as file:
txt = file.read()
for search, replace in lst_search_and_replace:
if search not in txt:
self.msg_error = (
f"Cannot find '{search}' in file '{filepath}'"
)
_logger.error(self.msg_error)
return False
txt = txt.replace(search, replace)
with open(filepath, "w") as file:
file.write(txt)
return True
@staticmethod
def validate_path_ready_to_be_override(name, directory, path=""):
if not path:
path = os.path.join(directory, name)
if not os.path.exists(path):
return True
# Check if in git
try:
git_repo = Repo(directory)
except NoSuchPathError:
_logger.error(f"Directory not existing '{directory}'")
return False
except InvalidGitRepositoryError:
_logger.error(
f"The path '{path}' exist, but no git repo, use force to"
" ignore it."
)
return False
status = git_repo.git.status(name, porcelain=True)
if status:
_logger.error(
f"The directory '{path}' has git difference, use force to"
" ignore it."
)
print(status)
return False
return True
@staticmethod
def restore_git_code_generator_demo(
code_generator_demo_path, relative_path
):
try:
git_repo = Repo(code_generator_demo_path)
except NoSuchPathError:
_logger.error(
f"Directory not existing '{code_generator_demo_path}'"
)
return False
except InvalidGitRepositoryError:
_logger.error(
f"The path '{code_generator_demo_path}' exist, but no git repo"
)
return False
git_repo.git.restore(relative_path)
def generate_module(self):
# TODO copy directory in temp workspace file before update it
module_path = os.path.join(self.module_directory, self.module_name)
if not self.force and not self.validate_path_ready_to_be_override(
self.module_name, self.module_directory, path=module_path
):
self.msg_error = f"Cannot generate on module path '{module_path}'"
_logger.error(self.msg_error)
return False
cg_path = os.path.join(self.cg_directory, self.cg_name)
cg_hooks_py = os.path.join(cg_path, "hooks.py")
if not self.force and not self.validate_path_ready_to_be_override(
self.cg_name, self.cg_directory, path=cg_path
):
self.msg_error = f"Cannot generate on cg path '{cg_path}'"
_logger.error(self.msg_error)
return False
template_path = os.path.join(
self.template_directory, self.template_name
)
template_hooks_py = os.path.join(template_path, "hooks.py")
if not self.force and not self.validate_path_ready_to_be_override(
self.template_name, self.template_directory, path=template_path
):
self.msg_error = (
f"Cannot generate on template path '{template_path}'"
)
_logger.error(self.msg_error)
return False
# Validate code_generator_demo
code_generator_demo_path = os.path.join(
CODE_GENERATOR_DIRECTORY, CODE_GENERATOR_DEMO_NAME
)
code_generator_demo_hooks_py = os.path.join(
code_generator_demo_path, "hooks.py"
)
code_generator_hooks_path_relative = os.path.join(
CODE_GENERATOR_DEMO_NAME, "hooks.py"
)
if not os.path.exists(code_generator_demo_path):
self.msg_error = (
"code_generator_demo is not accessible"
f" '{code_generator_demo_path}'"
)
_logger.error(self.msg_error)
return False
if not (
self.validate_path_ready_to_be_override(
CODE_GENERATOR_DEMO_NAME, CODE_GENERATOR_DIRECTORY
)
and self.search_and_replace_file(
code_generator_demo_hooks_py,
[
(
KEY_REPLACE_CODE_GENERATOR_DEMO
% CODE_GENERATOR_DEMO_NAME,
KEY_REPLACE_CODE_GENERATOR_DEMO % self.template_name,
),
(
'value["enable_sync_template"] = False',
'value["enable_sync_template"] = True',
),
(
"# path_module_generate ="
" os.path.normpath(os.path.join(os.path.dirname(__file__),"
" '..'))",
f'path_module_generate = "{self.module_directory}"',
),
(
'# "path_sync_code": path_module_generate,',
'"path_sync_code": path_module_generate,',
),
(
'# value["template_module_path_generated_extension"]'
' = "."',
'value["template_module_path_generated_extension"] ='
f' "{self.cg_directory}"',
),
],
)
):
return False
config_path = self.update_config()
bd_name_demo = f"new_project_code_generator_demo_{uuid.uuid4()}"[:63]
cmd = f"./script/database/db_restore.py --database {bd_name_demo}"
_logger.info(cmd)
os.system(cmd)
_logger.info("========= GENERATE code_generator_demo =========")
if self._coverage:
cmd = (
"./script/addons/coverage_install_addons_dev.sh"
f" {bd_name_demo} code_generator_demo {config_path}"
)
else:
cmd = (
f"./script/addons/install_addons_dev.sh {bd_name_demo}"
f" code_generator_demo {config_path}"
)
os.system(cmd)
if not self.keep_bd_alive:
cmd = "./odoo_bin.sh db --drop --database" f" {bd_name_demo}"
_logger.info(cmd)
os.system(cmd)
# Revert code_generator_demo
self.restore_git_code_generator_demo(
CODE_GENERATOR_DIRECTORY, code_generator_hooks_path_relative
)
# Validate
if not os.path.exists(template_path):
_logger.error(f"Module template not exists '{template_path}'")
return False
else:
_logger.info(f"Module template exists '{template_path}'")
lst_template_hooks_py_replace = [
(
'value["enable_template_wizard_view"] = False',
'value["enable_template_wizard_view"] = True',
),
]
# Add model from config
if self.config:
str_lst_model = "; ".join(
[a.get("name") for a in self.config_lst_model]
)
old_str = 'value["template_model_name"] = ""'
new_str = f'value["template_model_name"] = "{str_lst_model}"'
lst_template_hooks_py_replace.append((old_str, new_str))
self.search_and_replace_file(
template_hooks_py,
lst_template_hooks_py_replace,
)
# Execute all
bd_name_template = (
f"new_project_code_generator_template_{uuid.uuid4()}"[:63]
)
cmd = f"./script/database/db_restore.py --database {bd_name_template}"
os.system(cmd)
_logger.info(cmd)
_logger.info(f"========= GENERATE {self.template_name} =========")
# TODO maybe the module exist somewhere else
if os.path.exists(module_path):
# Install module before running code generator
cmd = (
"./script/code_generator/search_class_model.py --quiet -d"
f" {module_path} -t {template_path}"
)
_logger.info(cmd)
os.system(cmd)
if self._coverage:
cmd = (
"./script/addons/coverage_install_addons_dev.sh"
f" {bd_name_template} {self.module_name} {config_path}"
)
else:
cmd = (
f"./script/addons/install_addons_dev.sh {bd_name_template}"
f" {self.module_name} {config_path}"
)
_logger.info(cmd)
os.system(cmd)
if self._coverage:
cmd = (
"./script/addons/coverage_install_addons_dev.sh"
f" {bd_name_template} {self.template_name} {config_path}"
)
else:
cmd = (
f"./script/addons/install_addons_dev.sh {bd_name_template}"
f" {self.template_name} {config_path}"
)
_logger.info(cmd)
os.system(cmd)
if not self.keep_bd_alive:
cmd = "./odoo_bin.sh db --drop --database" f" {bd_name_template}"
_logger.info(cmd)
os.system(cmd)
# Validate
if not os.path.exists(cg_path):
_logger.error(f"Module cg not exists '{cg_path}'")
return False
else:
_logger.info(f"Module cg exists '{cg_path}'")
bd_name_generator = f"new_project_code_generator_{uuid.uuid4()}"[:63]
cmd = f"./script/database/db_restore.py --database {bd_name_generator}"
_logger.info(cmd)
os.system(cmd)
_logger.info(f"========= GENERATE {self.cg_name} =========")
# Add field from config
if self.config:
lst_update_cg = []
for model in self.config_lst_model:
model_name = model.get("name")
dct_field = {}
for a in model.get("fields"):
dct_value = {"ttype": a.get("type")}
if "relation" in a.keys():
dct_value["relation"] = a["relation"]
if "relation_field" in a.keys():
dct_value["relation_field"] = a["relation_field"]
if "description" in a.keys():
dct_value["field_description"] = a["description"]
dct_field[a.get("name")] = dct_value
if "name" not in dct_field.keys():
dct_field["name"] = {"ttype": "char"}
old_str = (
f'model_model = "{model_name}"\n '
" code_generator_id.add_update_model(model_model)"
)
new_str = (
f'model_model = "{model_name}"\n dct_field ='
f" {dct_field}\n "
" code_generator_id.add_update_model(model_model,"
" dct_field=dct_field)"
)
lst_update_cg.append((old_str, new_str))
# Force add menu and access
lst_update_cg.append(('"disable_generate_menu": True,', ""))
lst_update_cg.append(('"disable_generate_access": True,', ""))
self.search_and_replace_file(
cg_hooks_py,
lst_update_cg,
)
if self._coverage:
cmd = (
"./script/addons/coverage_install_addons_dev.sh"
f" {bd_name_generator} {self.cg_name} {config_path}"
)
else:
cmd = (
f"./script/addons/install_addons_dev.sh {bd_name_generator}"
f" {self.cg_name} {config_path}"
)
_logger.info(cmd)
os.system(cmd)
if not self.keep_bd_alive:
cmd = "./odoo_bin.sh db --drop --database" f" {bd_name_generator}"
_logger.info(cmd)
os.system(cmd)
# Validate
if not os.path.exists(module_path):
_logger.error(f"Module not exists '{module_path}'")
return False
else:
_logger.info(f"Module exists '{module_path}'")
return True
def update_config(self):
config = configparser.ConfigParser()
config.read(self.odoo_config)
addons_path = config.get("options", "addons_path")
lst_addons_path = addons_path.split(",")
lst_directory = list(
{
self.cg_directory,
self.module_directory,
self.template_directory,
}
)
has_change = False
for new_addons_path in lst_directory:
for actual_addons_path in lst_addons_path:
if not actual_addons_path:
continue
# Validate if not existing and valide is different path
relative_actual_addons_path = os.path.relpath(
actual_addons_path
)
relative_new_addons_path = os.path.relpath(new_addons_path)
if relative_actual_addons_path == relative_new_addons_path:
break
else:
lst_addons_path.insert(0, new_addons_path)
has_change = True
if has_change:
config.set("options", "addons_path", ",".join(lst_addons_path))
fd_i, temp_file = tempfile.mkstemp()
with open(temp_file, "w") as configfile:
config.write(configfile)
_logger.info(f"Create temporary config file: {temp_file}")
return temp_file
def main():
config = get_config()
if config.debug:
logging.getLogger().setLevel(logging.DEBUG)
project = ProjectManagement(
config.module,
config.directory,
cg_name=config.code_generator_name,
template_name=config.template_name,
force=config.force,
keep_bd_alive=config.keep_bd_alive,
coverage=config.coverage,
config=config.config,
odoo_config=config.odoo_config,
)
if project.msg_error:
return -1
if not project.generate_module():
return -1
return 0
if __name__ == "__main__":
sys.exit(main())