strx-precommit 0.1.1__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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: strx-precommit
3
+ Version: 0.1.1
4
+ Summary: Pre-commit hooks para conversión de strings y traducción de archivos PO
5
+ Home-page: https://github.com/straconxsa/strx_tools/
6
+ Author: Tu Nombre
7
+ Author-email: Initium Team <info@straconx.com>
8
+ Project-URL: Homepage, https://github.com/straconxsa/strx_tools/
9
+ Project-URL: Bug Tracker, https://github.com/straconxsa/strx_tools/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.6
14
+ Description-Content-Type: text/markdown
15
+ Dynamic: author
16
+ Dynamic: home-page
17
+ Dynamic: requires-python
File without changes
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "strx-precommit"
7
+ version = "0.1.1"
8
+ description = "Pre-commit hooks para conversión de strings y traducción de archivos PO"
9
+ readme = "README.md"
10
+ requires-python = ">=3.6"
11
+ authors = [
12
+ { name = "Initium Team", email = "info@straconx.com" },
13
+ ]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+
20
+ [project.urls]
21
+ "Homepage" = "https://github.com/straconxsa/strx_tools/"
22
+ "Bug Tracker" = "https://github.com/straconxsa/strx_tools/issues"
23
+
24
+ [tool.black]
25
+ line-length = 88
26
+ target-version = ["py36", "py37", "py38", "py39"]
27
+ include = '\.pyi?$'
28
+
29
+ [tool.isort]
30
+ profile = "black"
31
+ multi_line_output = 3
32
+
33
+ [tool.mypy]
34
+ python_version = "3.6"
35
+ warn_return_any = true
36
+ warn_unused_configs = true
37
+ mypy_path = "src"
38
+
39
+ [tool.pytest.ini_options]
40
+ minversion = "6.0"
41
+ addopts = "-ra -q"
42
+ testpaths = [
43
+ "tests",
44
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,49 @@
1
+ # Estructura de directorios:
2
+ # strx_precommit/
3
+ # ├── src/
4
+ # │ └── strx_precommit/
5
+ # │ ├── __init__.py
6
+ # │ ├── strx_convert_string.py
7
+ # │ └── strx_translate_po.py
8
+ # ├── setup.py
9
+ # ├── pyproject.toml
10
+ # └── README.md
11
+
12
+ # setup.py
13
+ from setuptools import setup, find_packages
14
+
15
+ setup(
16
+ name="strx-precommit",
17
+ version="0.1.1",
18
+ packages=find_packages(where="src"),
19
+ package_dir={"": "src"},
20
+ entry_points={
21
+ "console_scripts": [
22
+ "strx-precommit=strx_precommit.strx_convert_string:main",
23
+ "strx-convert-string=strx_precommit.strx_convert_string:main",
24
+ "strx-translate-po=strx_precommit.strx_translate_po:main",
25
+ "strx-update-po=strx_precommit.strx_update_po:main",
26
+ ],
27
+ },
28
+ install_requires=[
29
+ "pre-commit",
30
+ "polib",
31
+ "translate",
32
+ "GitPython",
33
+ ],
34
+ author="Tu Nombre",
35
+ author_email="tu@email.com",
36
+ description="Pre-commit hooks para conversión de strings y traducción de archivos PO",
37
+ long_description=open("README.md").read(),
38
+ long_description_content_type="text/markdown",
39
+ url="https://github.com/straconxsa/strx_tools/",
40
+ project_urls={
41
+ "Bug Tracker": "https://github.com/straconxsa/strx_tools/issues",
42
+ },
43
+ classifiers=[
44
+ "Programming Language :: Python :: 3",
45
+ "License :: OSI Approved :: MIT License",
46
+ "Operating System :: OS Independent",
47
+ ],
48
+ python_requires=">=3.6",
49
+ )
@@ -0,0 +1,5 @@
1
+ from .strx_convert_string import StringFormatConverter
2
+ from .strx_translate_po import OdooPoTranslator
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["StringFormatConverter", "OdooPoTranslator"]
@@ -0,0 +1,172 @@
1
+
2
+ import os
3
+ import re
4
+ import logging
5
+ import argparse
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from typing import List, Dict, Set, Optional
9
+
10
+ _logger = logging.getLogger(__name__)
11
+
12
+ # Pattern for multiline string formatting (e.g., "hello %s" % var)
13
+ STRING_MODULO_PATTERN = r'(\_\()?\s*(["\'])(.*?%s.*?)\2\)?\s*%\s*([^)\n,]+?|\([^)]+\))(?=\n|\)|$)'
14
+
15
+ # Default blacklist for files and directories
16
+ DEFAULT_BLACKLIST = {
17
+ "venv",
18
+ "__pycache__",
19
+ "migrations",
20
+ "test_*.py",
21
+ "setup.py",
22
+ ".*env.*",
23
+ }
24
+
25
+ @dataclass
26
+ class FileChange:
27
+ """Stores information about a single string format change."""
28
+ file_path: str
29
+ original_line: str
30
+ modified_line: str
31
+ line_number: int
32
+
33
+ class ChangeTracker:
34
+ """Tracks changes made during the conversion process."""
35
+ def __init__(self):
36
+ self.changes: Dict[str, List[FileChange]] = {}
37
+ self.files_reviewed: Set[str] = set()
38
+ self.files_modified: Set[str] = set()
39
+ self.files_skipped: Set[str] = set()
40
+ self.total_modifications = 0
41
+
42
+ def add_change(self, file_path: str, original: str, modified: str, line_number: int):
43
+ if file_path not in self.changes:
44
+ self.changes[file_path] = []
45
+ self.changes[file_path].append(FileChange(file_path, original, modified, line_number))
46
+ self.files_modified.add(file_path)
47
+ self.total_modifications += 1
48
+
49
+ def add_reviewed_file(self, file_path: str):
50
+ self.files_reviewed.add(file_path)
51
+
52
+ class StringFormatConverter:
53
+ """Class for converting string formats from %s to f-strings or .format()."""
54
+ def __init__(self, blacklist: Optional[Set[str]] = None, use_fstrings: bool = True):
55
+ self.blacklist = blacklist or DEFAULT_BLACKLIST
56
+ self.use_fstrings = use_fstrings
57
+ self.tracker = ChangeTracker()
58
+
59
+ def convert_to_fstring(self, string_content: str, variable: str) -> str:
60
+ """Convert a string with %s and its variable to an f-string."""
61
+ # Clean variable if it's a tuple (var,)
62
+ clean_var = variable.strip()
63
+ if clean_var.startswith('(') and clean_var.endswith(')'):
64
+ clean_var = clean_var[1:-1].strip().rstrip(',')
65
+
66
+ # Replace %s with {var}
67
+ # Note: This handles single %s.
68
+ if string_content.count('%s') == 1:
69
+ # Check if variable is simple enough for f-string (no complex logic)
70
+ # Actually f-strings handle expressions, so it's mostly fine
71
+ return string_content.replace('%s', '{' + clean_var + '}')
72
+ return None
73
+
74
+ def process_file(self, file_path: str) -> None:
75
+ try:
76
+ self.tracker.add_reviewed_file(file_path)
77
+ with open(file_path, "r", encoding="utf-8") as file:
78
+ content = file.read()
79
+
80
+ def replace_logic(match):
81
+ has_translation = bool(match.group(1))
82
+ quote = match.group(2)
83
+ string_content = match.group(3)
84
+ variable = match.group(4).strip()
85
+
86
+ line_number = content[:match.start()].count("\n") + 1
87
+
88
+ # If it's a translation, we MUST use .format() because f-strings are not translatable
89
+ if has_translation:
90
+ # Note: Simplified variable name mapping for .format()
91
+ # We use the variable name itself as the key if it's simple
92
+ safe_var = re.sub(r'[^a-zA-Z0-9_]', '_', variable.split('.')[-1].strip('()'))
93
+ if not safe_var or safe_var[0].isdigit(): safe_var = "var_" + safe_var
94
+
95
+ new_str = string_content.replace('%s', '{' + safe_var + '}')
96
+ result = f"_({quote}{new_str}{quote}).format({safe_var}={variable})"
97
+ else:
98
+ # If not translated, try f-string
99
+ f_content = self.convert_to_fstring(string_content, variable)
100
+ if f_content and self.use_fstrings:
101
+ # Ensure we don't duplicate f prefix if already there (though regex doesn't match it)
102
+ result = f"f{quote}{f_content}{quote}"
103
+ else:
104
+ # Fallback to .format() if f-string logic fails or disabled
105
+ safe_var = re.sub(r'[^a-zA-Z0-9_]', '_', variable.split('.')[-1].strip('()'))
106
+ if not safe_var or safe_var[0].isdigit(): safe_var = "var_" + safe_var
107
+ new_str = string_content.replace('%s', '{' + safe_var + '}')
108
+ result = f"{quote}{new_str}{quote}.format({safe_var}={variable})"
109
+
110
+ self.tracker.add_change(file_path, match.group(0), result, line_number)
111
+ return result
112
+
113
+ new_content = re.sub(STRING_MODULO_PATTERN, replace_logic, content, flags=re.MULTILINE | re.DOTALL)
114
+
115
+ if new_content != content:
116
+ with open(file_path, "w", encoding="utf-8") as file:
117
+ file.write(new_content)
118
+ except Exception as e:
119
+ _logger.error(f"Error processing {file_path}: {e}")
120
+
121
+ def run(self, directory: str):
122
+ directory = os.path.abspath(directory)
123
+ for root, dirs, files in os.walk(directory):
124
+ # Apply blacklist to directories
125
+ dirs[:] = [d for d in dirs if not any(re.match(b.replace('*', '.*'), d) for b in self.blacklist)]
126
+
127
+ for file in files:
128
+ if file.endswith(".py"):
129
+ file_path = os.path.join(root, file)
130
+ if any(re.match(b.replace('*', '.*'), file) for b in self.blacklist):
131
+ continue
132
+ self.process_file(file_path)
133
+
134
+ def main():
135
+ parser = argparse.ArgumentParser(description="Convert Python string formats from %s to f-strings.")
136
+ group = parser.add_mutually_exclusive_group()
137
+ group.add_argument("--all", action="store_true", help="Procesar todo el repositorio (global)")
138
+ group.add_argument("--path", "-p", help="Procesar un directorio específico")
139
+
140
+ args = parser.parse_args()
141
+
142
+ # Determine base directory
143
+ if args.all:
144
+ try:
145
+ root = subprocess.run(["git", "rev-parse", "--show-toplevel"],
146
+ capture_output=True, text=True, check=True).stdout.strip()
147
+ directory = root
148
+ except:
149
+ directory = os.getcwd()
150
+ print(f"Modo GLOBAL: Procesando desde {directory}")
151
+ elif args.path:
152
+ directory = os.path.abspath(args.path)
153
+ print(f"Modo DIRECTORIO: Procesando {directory}")
154
+ else:
155
+ directory = os.getcwd()
156
+ print(f"Modo LOCAL: Procesando directorio actual {directory}")
157
+
158
+ if not os.path.exists(directory):
159
+ print(f"Error: La ruta no existe: {directory}")
160
+ return
161
+
162
+ converter = StringFormatConverter()
163
+ converter.run(directory)
164
+
165
+ summary = converter.tracker
166
+ print(f"\nResumen de Conversión (a f-strings):")
167
+ print(f" - Archivos revisados: {len(summary.files_reviewed)}")
168
+ print(f" - Archivos modificados: {len(summary.files_modified)}")
169
+ print(f" - Total cambios realizados: {summary.total_modifications}")
170
+
171
+ if __name__ == "__main__":
172
+ main()
@@ -0,0 +1,179 @@
1
+
2
+ import os
3
+ import re
4
+ import glob
5
+ import polib
6
+ import logging
7
+ import sys
8
+ import subprocess
9
+ import argparse
10
+ from datetime import datetime
11
+ from babel.messages.catalog import Catalog
12
+ from babel.core import UnknownLocaleError, Locale
13
+ from pathlib import Path
14
+ from typing import Set, Dict, List, Optional, Tuple
15
+ from time import sleep
16
+ from translate import Translator
17
+
18
+ # Configuración de logging
19
+ logging.basicConfig(
20
+ level=logging.INFO, format="%(message)s", datefmt="%Y-%m-%d %H:%M:%S"
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+ class OdooPoTranslator:
25
+ """Gestiona la traducción de archivos PO en módulos Odoo"""
26
+
27
+ ODOO_LANGUAGE_CODES = {
28
+ "es": "es_ES",
29
+ "es_ES": "es_ES",
30
+ "es_CO": "es_CO",
31
+ "en": "en_US",
32
+ "en_US": "en_US",
33
+ "fr": "fr_FR",
34
+ "pt": "pt_PT",
35
+ "pt_BR": "pt_BR",
36
+ "de": "de_DE",
37
+ "it": "it_IT",
38
+ "nl": "nl_NL",
39
+ "pl": "pl_PL",
40
+ "ru": "ru_RU",
41
+ "zh": "zh_CN",
42
+ "ja": "ja_JP",
43
+ "ko": "ko_KR",
44
+ }
45
+
46
+ def __init__(self, source_dir: str, locale: str = "es"):
47
+ self.source_dir = os.path.abspath(source_dir)
48
+ self.locale = self._normalize_locale(locale)
49
+
50
+ if not self.locale:
51
+ raise ValueError(f"Idioma no soportado: {locale}")
52
+
53
+ try:
54
+ self.catalog = Catalog(locale=self.locale)
55
+ except UnknownLocaleError:
56
+ base_locale = self.locale.split("_")[0]
57
+ self.catalog = Catalog(locale=base_locale)
58
+
59
+ self.current_strings = set()
60
+ self.translation_markers = [r'_\([\'"](.+?)[\'"]\)', r'_lt\([\'"](.+?)[\'"]\)', r'_t\([\'"](.+?)[\'"]\)']
61
+ self.xml_patterns = [r'string="([^"]+)"', r'help="([^"]+)"', r'placeholder="([^"]+)"', r'title="([^"]+)"']
62
+
63
+ def _normalize_locale(self, locale: str) -> Optional[str]:
64
+ locale = locale.lower()
65
+ if locale in self.ODOO_LANGUAGE_CODES.values(): return locale
66
+ if locale in self.ODOO_LANGUAGE_CODES: return self.ODOO_LANGUAGE_CODES[locale]
67
+ return None
68
+
69
+ def scan_directory(self) -> None:
70
+ logger.info(f"🔍 Escaneando: {self.source_dir}")
71
+ for root, _, files in os.walk(self.source_dir):
72
+ for file in files:
73
+ filepath = os.path.join(root, file)
74
+ if file.endswith(".py"): self.scan_python_file(filepath)
75
+ elif file.endswith(".xml"): self.scan_xml_file(filepath)
76
+
77
+ def scan_python_file(self, filepath: str) -> None:
78
+ try:
79
+ with open(filepath, "r", encoding="utf-8") as f:
80
+ content = f.read()
81
+ for pattern in self.translation_markers:
82
+ for match in re.finditer(pattern, content):
83
+ text = match.group(1)
84
+ if text and not text.isspace():
85
+ line_no = content[:match.start()].count("\n") + 1
86
+ self.catalog.add(text, locations=[(filepath, line_no)])
87
+ self.current_strings.add(text)
88
+ except: pass
89
+
90
+ def scan_xml_file(self, filepath: str) -> None:
91
+ try:
92
+ with open(filepath, "r", encoding="utf-8") as f:
93
+ content = f.read()
94
+ for pattern in self.xml_patterns:
95
+ for match in re.finditer(pattern, content):
96
+ text = match.group(1).strip()
97
+ if text and not text.isspace() and len(text) > 1:
98
+ line_no = content[:match.start()].count("\n") + 1
99
+ self.catalog.add(text, locations=[(filepath, line_no)])
100
+ self.current_strings.add(text)
101
+ except: pass
102
+
103
+ def translate_pending_strings(self) -> None:
104
+ untranslated = [m.id for m in self.catalog if not m.string]
105
+ if not untranslated: return
106
+
107
+ logger.info(f"🔄 Traduciendo {len(untranslated)} strings...")
108
+ translator = Translator(to_lang=self.locale.split("_")[0], from_lang="en")
109
+
110
+ for text in untranslated:
111
+ try:
112
+ translation = translator.translate(text)
113
+ if translation and translation != text:
114
+ self.catalog.get(text).string = translation
115
+ logger.info(f" ✅ {text} -> {translation}")
116
+ sleep(0.1)
117
+ except: pass
118
+
119
+ def save(self):
120
+ po_path = os.path.join(self.source_dir, "i18n", f"{self.locale}.po")
121
+ os.makedirs(os.path.dirname(po_path), exist_ok=True)
122
+
123
+ po = polib.POFile()
124
+ po.metadata = {"Content-Type": "text/plain; charset=UTF-8"}
125
+ for msg in self.catalog:
126
+ entry = polib.POEntry(msgid=msg.id, msgstr=msg.string or "", occurrences=msg.locations)
127
+ po.append(entry)
128
+ po.save(po_path)
129
+ logger.info(f"💾 Guardado: {po_path}")
130
+
131
+ def main():
132
+ parser = argparse.ArgumentParser(description="Traducción automática de módulos Odoo.")
133
+ group = parser.add_mutually_exclusive_group()
134
+ group.add_argument("--all", action="store_true", help="Procesar todo el repositorio (global)")
135
+ group.add_argument("--path", "-p", help="Procesar un directorio específico")
136
+ parser.add_argument("--locale", default="es", help="Idioma (default: es)")
137
+
138
+ args = parser.parse_args()
139
+
140
+ if args.all:
141
+ try:
142
+ root = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=True).stdout.strip()
143
+ directory = root
144
+ except: directory = os.getcwd()
145
+ elif args.path: directory = os.path.abspath(args.path)
146
+ else: directory = os.getcwd()
147
+
148
+ if not os.path.exists(directory):
149
+ print(f"Error: {directory} no existe")
150
+ return
151
+
152
+ # Si es global, buscamos módulos (carpetas con __manifest__.py)
153
+ modules = []
154
+ if args.all:
155
+ for root, dirs, files in os.walk(directory):
156
+ if "__manifest__.py" in files:
157
+ modules.append(root)
158
+ dirs[:] = [] # No buscar sub-módulos
159
+ else:
160
+ if os.path.exists(os.path.join(directory, "__manifest__.py")):
161
+ modules.append(directory)
162
+ else:
163
+ # Quizás es un directorio de módulos
164
+ for d in os.listdir(directory):
165
+ dpath = os.path.join(directory, d)
166
+ if os.path.isdir(dpath) and os.path.exists(os.path.join(dpath, "__manifest__.py")):
167
+ modules.append(dpath)
168
+
169
+ for mod in modules:
170
+ try:
171
+ t = OdooPoTranslator(mod, args.locale)
172
+ t.scan_directory()
173
+ t.translate_pending_strings()
174
+ t.save()
175
+ except Exception as e:
176
+ logger.error(f"Error en {mod}: {e}")
177
+
178
+ if __name__ == "__main__":
179
+ main()
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import git
4
+ import sys
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Set, Dict, List, Optional, Tuple
8
+ from .strx_translate_po import OdooPoTranslator
9
+ import argparse
10
+
11
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_DIR = "/home/initium16/odoo16/initium"
15
+
16
+
17
+ class GitModuleDetector:
18
+ """Detecta módulos Odoo modificados en git usando comandos directos"""
19
+
20
+ def __init__(self, repo_path: str = "."):
21
+ self.repo_path = os.path.abspath(repo_path) if repo_path != "." else DEFAULT_DIR
22
+ try:
23
+ self.repo = git.Repo(self.repo_path)
24
+ os.chdir(self.repo_path)
25
+ except git.InvalidGitRepositoryError:
26
+ logger.debug(f"No es un repositorio git válido: {repo_path}")
27
+ self.repo = None
28
+
29
+ def _is_odoo_module(self, path: str) -> bool:
30
+ """Verifica si una ruta es un módulo Odoo válido"""
31
+ manifest_path = os.path.join(
32
+ path, "__manifest__.py"
33
+ ) # Nombre correcto del manifest
34
+ return os.path.isfile(manifest_path)
35
+
36
+ def get_modified_files(self) -> Set[str]:
37
+ """Obtiene lista de archivos modificados usando comandos git directos"""
38
+ if not self.repo:
39
+ return set()
40
+
41
+ changed_files = set()
42
+
43
+ try:
44
+ # Obtener cambios usando git status
45
+ status_output = self.repo.git.status("--porcelain", "--untracked-files=all")
46
+
47
+ if status_output:
48
+ for line in status_output.split("\n"):
49
+ if line:
50
+ status = line[:2]
51
+ file_path = line[3:]
52
+ if status.strip() != "D": # Ignorar archivos eliminados
53
+ changed_files.add(file_path)
54
+ logger.debug(f"Archivo detectado ({status}): {file_path}")
55
+
56
+ # Si no hay cambios en el status, verificar el último commit
57
+ if not changed_files and self.repo.head.is_valid():
58
+ try:
59
+ last_commit = self.repo.head.commit
60
+ if last_commit.parents:
61
+ changed_files.update(
62
+ item.a_path
63
+ for item in last_commit.diff(last_commit.parents[0])
64
+ )
65
+ except Exception as e:
66
+ logger.debug(f"Error verificando último commit: {e}")
67
+
68
+ except Exception as e:
69
+ logger.error(f"Error obteniendo archivos modificados: {e}")
70
+
71
+ return changed_files
72
+
73
+ def find_module_from_file(self, file_path: str) -> Optional[str]:
74
+ """
75
+ Encuentra el módulo Odoo que contiene un archivo, buscando __manifest__.py
76
+ """
77
+ try:
78
+ current_path = os.path.dirname(os.path.join(self.repo_path, file_path))
79
+
80
+ while (
81
+ current_path
82
+ and os.path.commonpath([current_path, self.repo_path]) == self.repo_path
83
+ ):
84
+ if self._is_odoo_module(current_path):
85
+ logger.debug(f"Módulo Odoo encontrado en: {current_path}")
86
+ return current_path
87
+ parent_path = os.path.dirname(current_path)
88
+ if parent_path == current_path: # Llegamos a la raíz
89
+ break
90
+ current_path = parent_path
91
+
92
+ except Exception as e:
93
+ logger.debug(f"Error buscando módulo para {file_path}: {e}")
94
+
95
+ return None
96
+
97
+ def get_modified_modules(self) -> Dict[str, Dict]:
98
+ """Obtiene información de todos los módulos Odoo modificados"""
99
+ modules = {}
100
+ changed_files = self.get_modified_files()
101
+
102
+ logger.debug(f"Archivos modificados encontrados: {len(changed_files)}")
103
+
104
+ for file_path in changed_files:
105
+ # Solo procesar archivos Python
106
+ if not file_path.endswith(".py"):
107
+ continue
108
+
109
+ logger.debug(f"Procesando archivo: {file_path}")
110
+ module_path = self.find_module_from_file(file_path)
111
+
112
+ if module_path and module_path not in modules:
113
+ module_info = self._get_module_info(module_path)
114
+ if module_info:
115
+ modules[module_path] = module_info
116
+ logger.info(
117
+ f"✓ Módulo detectado: {module_info['name']} ({module_info['technical_name']})"
118
+ )
119
+ logger.debug(f" Ruta: {module_path}")
120
+
121
+ return modules
122
+
123
+ def _get_module_info(self, module_path: str) -> Optional[Dict]:
124
+ """Lee la información del módulo desde __manifest__.py"""
125
+ manifest_path = os.path.join(module_path, "__manifest__.py")
126
+
127
+ try:
128
+ if not os.path.isfile(manifest_path):
129
+ logger.debug(f"No se encontró __manifest__.py en: {manifest_path}")
130
+ return None
131
+
132
+ with open(manifest_path, "r", encoding="utf-8") as f:
133
+ manifest_content = f.read()
134
+ manifest_dict = eval(manifest_content)
135
+
136
+ module_info = {
137
+ "name": manifest_dict.get("name", os.path.basename(module_path)),
138
+ "version": manifest_dict.get("version", "1.0"),
139
+ "technical_name": os.path.basename(module_path),
140
+ "path": module_path,
141
+ "manifest_path": manifest_path,
142
+ "summary": manifest_dict.get("summary", ""),
143
+ "category": manifest_dict.get("category", "Uncategorized"),
144
+ "author": manifest_dict.get("author", ""),
145
+ "depends": manifest_dict.get("depends", []),
146
+ "website": manifest_dict.get("website", ""),
147
+ "license": manifest_dict.get("license", "LGPL-3"),
148
+ }
149
+
150
+ logger.debug(f"Información del módulo leída: {module_info['name']}")
151
+ return module_info
152
+
153
+ except Exception as e:
154
+ logger.error(f"Error leyendo __manifest__.py en {manifest_path}: {e}")
155
+ return None
156
+
157
+ def print_module_summary(self, modules: Dict[str, Dict]):
158
+ """Imprime un resumen de los módulos encontrados"""
159
+ if not modules:
160
+ logger.info("No se encontraron módulos modificados")
161
+ return
162
+
163
+ logger.info("\n📦 Módulos modificados encontrados:")
164
+ for module_path, info in modules.items():
165
+ logger.info(f"\n▶ {info['name']} ({info['technical_name']})")
166
+ logger.info(f" 📍 Ruta: {module_path}")
167
+ if info["version"]:
168
+ logger.info(f" 📌 Versión: {info['version']}")
169
+ if info["depends"]:
170
+ logger.info(f" 🔗 Dependencias: {', '.join(info['depends'])}")
171
+
172
+
173
+ class ModuleDetector:
174
+ """Detecta módulos Odoo en un directorio o en cambios de git"""
175
+
176
+ def __init__(self, default_dir: Optional[str] = None, repo_path: str = "."):
177
+ self.default_dir = os.path.abspath(default_dir) if default_dir else None
178
+ self.repo_path = os.path.abspath(repo_path)
179
+ self.git_detector = GitModuleDetector(repo_path)
180
+
181
+ def _is_odoo_module(self, path: str) -> bool:
182
+ """Verifica si una ruta es un módulo Odoo válido"""
183
+ manifest_path = os.path.join(path, "__manifest__.py")
184
+ return os.path.isfile(manifest_path)
185
+
186
+ def _read_manifest(self, manifest_path: str) -> Optional[Dict]:
187
+ """Lee y retorna la información del manifest"""
188
+ try:
189
+ with open(manifest_path, "r", encoding="utf-8") as f:
190
+ manifest_content = f.read()
191
+ return eval(manifest_content)
192
+ except Exception as e:
193
+ logger.debug(f"Error leyendo manifest {manifest_path}: {e}")
194
+ return None
195
+
196
+ def _get_module_info(self, module_path: str) -> Optional[Dict]:
197
+ """Obtiene información detallada de un módulo"""
198
+ manifest_path = os.path.join(module_path, "__manifest__.py")
199
+
200
+ try:
201
+ if not os.path.isfile(manifest_path):
202
+ logger.debug(f"No se encontró __manifest__.py en: {manifest_path}")
203
+ return None
204
+
205
+ manifest_data = self._read_manifest(manifest_path)
206
+ if not manifest_data:
207
+ return None
208
+
209
+ module_info = {
210
+ "name": manifest_data.get("name", os.path.basename(module_path)),
211
+ "version": manifest_data.get("version", "1.0"),
212
+ "technical_name": os.path.basename(module_path),
213
+ "path": module_path,
214
+ "manifest_path": manifest_path,
215
+ "summary": manifest_data.get("summary", ""),
216
+ "author": manifest_data.get("author", ""),
217
+ "depends": manifest_data.get("depends", []),
218
+ "category": manifest_data.get("category", "Uncategorized"),
219
+ "website": manifest_data.get("website", ""),
220
+ "license": manifest_data.get("license", "LGPL-3"),
221
+ }
222
+
223
+ logger.debug(f"Información del módulo leída: {module_info['name']}")
224
+ return module_info
225
+
226
+ except Exception as e:
227
+ logger.error(f"Error procesando módulo en {module_path}: {e}")
228
+ if logger.getEffectiveLevel() == logging.DEBUG:
229
+ import traceback
230
+
231
+ logger.debug(traceback.format_exc())
232
+ return None
233
+
234
+ def find_modules(self) -> Dict[str, Dict]:
235
+ """Encuentra módulos Odoo basado en directorio predeterminado y/o cambios git"""
236
+ modules = {}
237
+
238
+ # 1. Buscar en directorio predeterminado si está configurado
239
+ if self.default_dir and os.path.exists(self.default_dir):
240
+ logger.debug(f"Buscando en directorio predeterminado: {self.default_dir}")
241
+ for item in os.listdir(self.default_dir):
242
+ module_path = os.path.join(self.default_dir, item)
243
+ if os.path.isdir(module_path) and self._is_odoo_module(module_path):
244
+ module_info = self._get_module_info(module_path)
245
+ if module_info:
246
+ modules[module_path] = module_info
247
+ logger.debug(
248
+ f"Módulo encontrado en directorio: {module_info['name']}"
249
+ )
250
+
251
+ # 2. Buscar en cambios git
252
+ logger.debug("Buscando módulos en cambios git...")
253
+ git_modules = self.git_detector.get_modified_modules()
254
+
255
+ # Combinar resultados, priorizando información de git para módulos duplicados
256
+ modules.update(git_modules)
257
+
258
+ if modules:
259
+ self._print_module_summary(modules)
260
+ else:
261
+ logger.info("No se encontraron módulos para procesar")
262
+
263
+ return modules
264
+
265
+ def _print_module_summary(self, modules: Dict[str, Dict]):
266
+ """Imprime un resumen de los módulos encontrados"""
267
+ logger.info("\n📦 Módulos encontrados:")
268
+
269
+ for module_path, info in modules.items():
270
+ logger.info(f"\n▶ {info['name']} ({info['technical_name']})")
271
+ logger.info(f" 📍 Ruta: {module_path}")
272
+
273
+ if info["version"]:
274
+ logger.info(f" 📌 Versión: {info['version']}")
275
+
276
+ if info["category"] != "Uncategorized":
277
+ logger.info(f" 📑 Categoría: {info['category']}")
278
+
279
+ if info["author"]:
280
+ logger.info(f" 👤 Autor: {info['author']}")
281
+
282
+ if info["depends"]:
283
+ logger.info(f" 🔗 Dependencias: {', '.join(info['depends'])}")
284
+
285
+ if info["website"]:
286
+ logger.info(f" 🌐 Website: {info['website']}")
287
+
288
+ logger.info(f" ⚖️ Licencia: {info['license']}")
289
+
290
+ def get_module_dependencies(self, module_path: str) -> List[str]:
291
+ """Obtiene la lista de dependencias de un módulo"""
292
+ module_info = self._get_module_info(module_path)
293
+ if module_info and module_info["depends"]:
294
+ return module_info["depends"]
295
+ return []
296
+
297
+ def is_module_installable(self, module_path: str) -> bool:
298
+ """Verifica si un módulo está marcado como instalable"""
299
+ manifest_path = os.path.join(module_path, "__manifest__.py")
300
+ if os.path.isfile(manifest_path):
301
+ manifest_data = self._read_manifest(manifest_path)
302
+ return manifest_data.get("installable", True) if manifest_data else False
303
+ return False
304
+
305
+
306
+ class TranslationChecker:
307
+ """Verifica traducciones en módulos Odoo"""
308
+
309
+ def __init__(self, lang: str = "es", default_dir: Optional[str] = None):
310
+ self.lang = lang
311
+ self.module_detector = ModuleDetector(default_dir)
312
+
313
+ def check_translations(self) -> bool:
314
+ """Verifica y actualiza traducciones en módulos"""
315
+ modules = self.module_detector.find_modules()
316
+
317
+ if not modules:
318
+ logger.info("✅ No se encontraron módulos para verificar")
319
+ return True
320
+
321
+ logger.info(f"\n🔍 Verificando traducciones ({self.lang}):")
322
+
323
+ all_ok = True
324
+ modules_with_issues = {}
325
+ new_modules = []
326
+
327
+ for module_path, module_info in modules.items():
328
+ logger.info(
329
+ f"\n📦 Verificando módulo: {module_info['name']} ({module_info['technical_name']})"
330
+ )
331
+
332
+ po_path = os.path.join(module_path, "i18n", f"{self.lang}.po")
333
+
334
+ if not os.path.exists(po_path):
335
+ new_modules.append(module_info)
336
+ all_ok = False
337
+ logger.error(f"❌ No se encontró archivo {self.lang}.po")
338
+ continue
339
+
340
+ # Crear instancia del traductor
341
+ translator = OdooPoTranslator(module_path, self.lang)
342
+
343
+ # Escanear y procesar el módulo
344
+ translator.scan_directory()
345
+
346
+ # Verificar strings sin traducir antes de la traducción
347
+ untranslated_before = translator.get_untranslated_strings()
348
+
349
+ if untranslated_before:
350
+ logger.info(
351
+ f"Encontrados {len(untranslated_before)} strings sin traducir"
352
+ )
353
+
354
+ # Realizar la traducción
355
+ logger.info("Iniciando proceso de traducción...")
356
+ translator.translate_pending_strings()
357
+
358
+ # Guardar las traducciones
359
+ translator.save_po_file()
360
+
361
+ # Verificar strings sin traducir después de la traducción
362
+ untranslated_after = translator.get_untranslated_strings()
363
+
364
+ if untranslated_after:
365
+ all_ok = False
366
+ modules_with_issues[module_path] = untranslated_after
367
+ logger.error("❌ Strings que no se pudieron traducir:")
368
+ for string in sorted(untranslated_after):
369
+ logger.error(f" - {string}")
370
+ else:
371
+ logger.info("✅ Todas las cadenas fueron traducidas exitosamente")
372
+
373
+ if not all_ok:
374
+ self._show_error_summary(new_modules, modules_with_issues)
375
+ return False
376
+
377
+ logger.info("\n✅ Todas las traducciones están actualizadas")
378
+ return True
379
+
380
+ def _show_error_summary(self, new_modules: List[Dict], modules_with_issues: Dict):
381
+ """Muestra resumen de errores encontrados"""
382
+ logger.error("\n❌ Se encontraron problemas de traducción")
383
+
384
+ if new_modules:
385
+ logger.error("\n📋 Módulos que requieren traducción inicial:")
386
+ for module in new_modules:
387
+ logger.error(f" - {module['name']} ({module['technical_name']})")
388
+ logger.error("\nPara generar traducciones iniciales desde Odoo:")
389
+ logger.error("1. Instala el módulo en Odoo")
390
+ logger.error("2. Ve a Ajustes > Traducciones > Cargar una traducción")
391
+ logger.error(f"3. Selecciona el idioma ({self.lang})")
392
+ logger.error("4. Marca 'Sobreescribir términos existentes'")
393
+ logger.error("5. Selecciona los módulos listados arriba")
394
+
395
+ if modules_with_issues:
396
+ logger.error("\n📝 Resumen de strings que no se pudieron traducir:")
397
+ for module_path, strings in modules_with_issues.items():
398
+ module_name = os.path.basename(module_path)
399
+ logger.error(f"\nMódulo: {module_name}")
400
+ for string in sorted(strings):
401
+ logger.error(f" - {string}")
402
+
403
+ logger.error("\n⚠️ Por favor, verifica las traducciones pendientes manualmente")
404
+
405
+
406
+ def main():
407
+
408
+ parser = argparse.ArgumentParser(
409
+ description="Verifica traducciones en módulos Odoo"
410
+ )
411
+ parser.add_argument("--lang", default="es", help="Código de idioma (default: es)")
412
+ parser.add_argument(
413
+ "--dir", dest="default_dir", help="Directorio predeterminado de módulos Odoo"
414
+ )
415
+ parser.add_argument(
416
+ "--git-only",
417
+ action="store_true",
418
+ help="Solo verificar módulos con cambios en git",
419
+ )
420
+ parser.add_argument("--debug", action="store_true", help="Activa logs de debug")
421
+ parser.add_argument(
422
+ "--no-translate", action="store_true", help="No realizar traducción automática"
423
+ )
424
+
425
+ args = parser.parse_args()
426
+
427
+ if args.debug:
428
+ logger.setLevel(logging.DEBUG)
429
+
430
+ try:
431
+ # Si se especifica --git-only, no usar directorio predeterminado
432
+ GIT_ONLY = args.git_only or True
433
+ default_dir = DEFAULT_DIR if GIT_ONLY else args.default_dir
434
+
435
+ checker = TranslationChecker(lang=args.lang, default_dir=default_dir)
436
+
437
+ if not checker.check_translations():
438
+ sys.exit(1)
439
+
440
+ except Exception as e:
441
+ logger.error(f"\n❌ Error: {e}")
442
+ if args.debug:
443
+ import traceback
444
+
445
+ logger.debug(traceback.format_exc())
446
+ sys.exit(1)
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: strx-precommit
3
+ Version: 0.1.1
4
+ Summary: Pre-commit hooks para conversión de strings y traducción de archivos PO
5
+ Home-page: https://github.com/straconxsa/strx_tools/
6
+ Author: Tu Nombre
7
+ Author-email: Initium Team <info@straconx.com>
8
+ Project-URL: Homepage, https://github.com/straconxsa/strx_tools/
9
+ Project-URL: Bug Tracker, https://github.com/straconxsa/strx_tools/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.6
14
+ Description-Content-Type: text/markdown
15
+ Dynamic: author
16
+ Dynamic: home-page
17
+ Dynamic: requires-python
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ src/strx_precommit/__init__.py
5
+ src/strx_precommit/strx_convert_string.py
6
+ src/strx_precommit/strx_translate_po.py
7
+ src/strx_precommit/strx_update_po.py
8
+ src/strx_precommit.egg-info/PKG-INFO
9
+ src/strx_precommit.egg-info/SOURCES.txt
10
+ src/strx_precommit.egg-info/dependency_links.txt
11
+ src/strx_precommit.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ strx_precommit