pyreqifz 0.1.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.
pyreqifz-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ernesto Avedillo Carretero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyreqifz
3
+ Version: 0.1.0
4
+ Summary: Read, edit and repack ReqIF/ReqIFz requirement documents (OMG ReqIF standard)
5
+ Author: Ernesto Avedillo Carretero
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: openpyxl>=3.1.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: build>=1.0; extra == "dev"
14
+ Requires-Dist: twine>=5.0; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # pyreqif
18
+
19
+ Librería Python para leer, editar y reempaquetar documentos [ReqIF](https://www.omg.org/spec/ReqIF/) (`.reqif` / `.reqifz`), el estándar OMG usado habitualmente para intercambiar requisitos entre fabricante y proveedor (p.ej. lastenheft de automoción).
20
+
21
+ Dos clases:
22
+
23
+ - **`Reqif`**: un único fichero `.reqif`. Lee cada requisito (`SPEC-OBJECT`) con su texto, y dos campos configurables pensados para el flujo proveedor: comentario (`Kommentar Lieferant M`, XHTML) y estado (`Status Lieferant M`, enumerado). Permite editarlos, exportar/importar por Excel (con desplegable de validación para el estado) y detecta las imágenes embebidas en el texto de cada requisito.
24
+ - **`Reqifz`**: un `.reqifz` (zip con uno o varios `.reqif` más sus adjuntos). Extrae a un directorio de trabajo, expone cada `.reqif` como un `Reqif`, y reempaqueta todo de vuelta conservando los adjuntos intactos.
25
+
26
+ ## Instalación
27
+
28
+ ```bash
29
+ pip install pyreqif
30
+ ```
31
+
32
+ ## Uso básico
33
+
34
+ ```python
35
+ from pyreqif import Reqifz
36
+
37
+ with Reqifz("lastenheft.reqifz") as pack:
38
+ for doc in pack:
39
+ print(doc, "->", len(doc), "requisitos")
40
+
41
+ doc = pack.get(0) # o pack.get("nombre_del_fichero.reqif")
42
+
43
+ # leer
44
+ req = doc.get("_a1b2c3...")
45
+ print(req.text, req.comment, req.status, req.images)
46
+
47
+ # editar (None deja el campo igual, "" lo vacía)
48
+ doc.update("_a1b2c3...", comment="Aceptado, sin cambios.", status="akzeptiert")
49
+
50
+ # exportar/importar por Excel
51
+ doc.to_excel("requisitos.xlsx")
52
+ doc.update_from_excel("requisitos_revisado.xlsx")
53
+
54
+ # volver a empaquetar con los cambios
55
+ pack.save("lastenheft_editado.reqifz")
56
+ ```
57
+
58
+ Un `.reqif` suelto (sin comprimir) se usa igual, sin pasar por `Reqifz`:
59
+
60
+ ```python
61
+ from pyreqif import Reqif
62
+
63
+ doc = Reqif("documento.reqif")
64
+ doc.update("_a1b2c3...", status="Klärungsbedarf")
65
+ doc.save("documento_editado.reqif")
66
+ ```
67
+
68
+ ## Nombres de atributo
69
+
70
+ Por defecto se usan los nombres estándar de ReqIF para el texto (`ReqIF.Text` / `ReqIF.ChapterName` como respaldo) y los nombres de campo de este flujo concreto de proveedor (`Kommentar Lieferant M` / `Status Lieferant M`). Si tu documento usa otros nombres, crea una subclase:
71
+
72
+ ```python
73
+ from pyreqif import Reqif
74
+
75
+ class MiReqif(Reqif):
76
+ COMMENT_ATTR = "Supplier Comment"
77
+ STATUS_ATTR = "Supplier Status"
78
+ ```
79
+
80
+ ## Desarrollo
81
+
82
+ ```bash
83
+ uv venv
84
+ uv pip install -e ".[dev]"
85
+ uv run pytest
86
+ ```
87
+
88
+ ## Licencia
89
+
90
+ MIT
@@ -0,0 +1,74 @@
1
+ # pyreqif
2
+
3
+ Librería Python para leer, editar y reempaquetar documentos [ReqIF](https://www.omg.org/spec/ReqIF/) (`.reqif` / `.reqifz`), el estándar OMG usado habitualmente para intercambiar requisitos entre fabricante y proveedor (p.ej. lastenheft de automoción).
4
+
5
+ Dos clases:
6
+
7
+ - **`Reqif`**: un único fichero `.reqif`. Lee cada requisito (`SPEC-OBJECT`) con su texto, y dos campos configurables pensados para el flujo proveedor: comentario (`Kommentar Lieferant M`, XHTML) y estado (`Status Lieferant M`, enumerado). Permite editarlos, exportar/importar por Excel (con desplegable de validación para el estado) y detecta las imágenes embebidas en el texto de cada requisito.
8
+ - **`Reqifz`**: un `.reqifz` (zip con uno o varios `.reqif` más sus adjuntos). Extrae a un directorio de trabajo, expone cada `.reqif` como un `Reqif`, y reempaqueta todo de vuelta conservando los adjuntos intactos.
9
+
10
+ ## Instalación
11
+
12
+ ```bash
13
+ pip install pyreqif
14
+ ```
15
+
16
+ ## Uso básico
17
+
18
+ ```python
19
+ from pyreqif import Reqifz
20
+
21
+ with Reqifz("lastenheft.reqifz") as pack:
22
+ for doc in pack:
23
+ print(doc, "->", len(doc), "requisitos")
24
+
25
+ doc = pack.get(0) # o pack.get("nombre_del_fichero.reqif")
26
+
27
+ # leer
28
+ req = doc.get("_a1b2c3...")
29
+ print(req.text, req.comment, req.status, req.images)
30
+
31
+ # editar (None deja el campo igual, "" lo vacía)
32
+ doc.update("_a1b2c3...", comment="Aceptado, sin cambios.", status="akzeptiert")
33
+
34
+ # exportar/importar por Excel
35
+ doc.to_excel("requisitos.xlsx")
36
+ doc.update_from_excel("requisitos_revisado.xlsx")
37
+
38
+ # volver a empaquetar con los cambios
39
+ pack.save("lastenheft_editado.reqifz")
40
+ ```
41
+
42
+ Un `.reqif` suelto (sin comprimir) se usa igual, sin pasar por `Reqifz`:
43
+
44
+ ```python
45
+ from pyreqif import Reqif
46
+
47
+ doc = Reqif("documento.reqif")
48
+ doc.update("_a1b2c3...", status="Klärungsbedarf")
49
+ doc.save("documento_editado.reqif")
50
+ ```
51
+
52
+ ## Nombres de atributo
53
+
54
+ Por defecto se usan los nombres estándar de ReqIF para el texto (`ReqIF.Text` / `ReqIF.ChapterName` como respaldo) y los nombres de campo de este flujo concreto de proveedor (`Kommentar Lieferant M` / `Status Lieferant M`). Si tu documento usa otros nombres, crea una subclase:
55
+
56
+ ```python
57
+ from pyreqif import Reqif
58
+
59
+ class MiReqif(Reqif):
60
+ COMMENT_ATTR = "Supplier Comment"
61
+ STATUS_ATTR = "Supplier Status"
62
+ ```
63
+
64
+ ## Desarrollo
65
+
66
+ ```bash
67
+ uv venv
68
+ uv pip install -e ".[dev]"
69
+ uv run pytest
70
+ ```
71
+
72
+ ## Licencia
73
+
74
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyreqifz"
7
+ version = "0.1.0"
8
+ description = "Read, edit and repack ReqIF/ReqIFz requirement documents (OMG ReqIF standard)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ernesto Avedillo Carretero" }]
13
+ dependencies = [
14
+ "openpyxl>=3.1.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0",
20
+ "build>=1.0",
21
+ "twine>=5.0",
22
+ ]
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+ include = ["pyreqif*"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .reqif import Reqif, Requirement, ReqifError
2
+ from .reqifz import Reqifz
3
+
4
+ __all__ = ['Reqif', 'Reqifz', 'Requirement', 'ReqifError']
File without changes
@@ -0,0 +1,390 @@
1
+ """Reqif: lectura, edición y exportación de un único documento .reqif."""
2
+ from __future__ import annotations
3
+
4
+ import xml.etree.ElementTree as ET
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from openpyxl import Workbook, load_workbook
10
+ from openpyxl.styles import Alignment
11
+ from openpyxl.worksheet.datavalidation import DataValidation
12
+
13
+ XHTML_NS = 'http://www.w3.org/1999/xhtml'
14
+
15
+ EXCEL_HEADERS = ['ID', 'Texto', 'Kommentar Lieferant M', 'Status Lieferant M']
16
+
17
+ _DEFINITION_TAGS = [
18
+ 'ATTRIBUTE-DEFINITION-STRING',
19
+ 'ATTRIBUTE-DEFINITION-XHTML',
20
+ 'ATTRIBUTE-DEFINITION-ENUMERATION',
21
+ 'ATTRIBUTE-DEFINITION-INTEGER',
22
+ 'ATTRIBUTE-DEFINITION-BOOLEAN',
23
+ 'ATTRIBUTE-DEFINITION-DATE',
24
+ ]
25
+
26
+
27
+ class ReqifError(Exception):
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class Requirement:
33
+ """Un SPEC-OBJECT del ReqIF, con los campos relevantes ya resueltos."""
34
+ id: str
35
+ text: str
36
+ is_chapter: bool
37
+ comment: str
38
+ status: str
39
+ images: list[str] = field(default_factory=list)
40
+
41
+
42
+ class Reqif:
43
+ """Representa un único fichero .reqif (XML, estándar OMG ReqIF).
44
+
45
+ Se puede editar el comentario y el estado de cada requisito
46
+ (`Kommentar Lieferant M` / `Status Lieferant M` por defecto, pero los
47
+ nombres de atributo son configurables mediante subclase o los
48
+ parámetros de clase TEXT_ATTR/CHAPTER_ATTR/COMMENT_ATTR/STATUS_ATTR),
49
+ exportar a Excel con validación de datos para el estado, e importar
50
+ de vuelta un Excel modificado.
51
+ """
52
+
53
+ TEXT_ATTR = 'ReqIF.Text'
54
+ CHAPTER_ATTR = 'ReqIF.ChapterName'
55
+ COMMENT_ATTR = 'Kommentar Lieferant M'
56
+ STATUS_ATTR = 'Status Lieferant M'
57
+
58
+ def __init__(self, source):
59
+ """source: ruta a un .reqif, o un objeto tipo fichero ya abierto."""
60
+ if hasattr(source, 'read'):
61
+ self.path = None
62
+ self._tree = ET.parse(source)
63
+ else:
64
+ self.path = Path(source)
65
+ self._tree = ET.parse(self.path)
66
+
67
+ self._root = self._tree.getroot()
68
+ self._ns, self._uri = self._namespace()
69
+ self._definitions = self._build_definitions()
70
+ self._status_options = self._status_options_list()
71
+ self._requirements, self._by_id = self._parse_requirements()
72
+
73
+ # -- construcción --------------------------------------------------
74
+
75
+ def _namespace(self):
76
+ tag = self._root.tag
77
+ if '}' in tag:
78
+ uri = tag.split('}')[0].strip('{')
79
+ return {'r': uri}, uri
80
+ return {}, None
81
+
82
+ def _qn(self, tag):
83
+ return f'{{{self._uri}}}{tag}' if self._uri else tag
84
+
85
+ def _build_definitions(self):
86
+ ns = self._ns
87
+ root = self._root
88
+ enum_values_by_datatype = {}
89
+ for dt in root.findall('.//r:DATATYPE-DEFINITION-ENUMERATION', ns):
90
+ values = [
91
+ (ev.attrib.get('IDENTIFIER'), ev.attrib.get('LONG-NAME', ''))
92
+ for ev in dt.findall('.//r:ENUM-VALUE', ns)
93
+ ]
94
+ enum_values_by_datatype[dt.attrib.get('IDENTIFIER')] = values
95
+
96
+ definitions = {}
97
+ for tag in _DEFINITION_TAGS:
98
+ for el in root.findall(f'.//r:{tag}', ns):
99
+ ident = el.attrib.get('IDENTIFIER')
100
+ entry = {'long_name': el.attrib.get('LONG-NAME', ''), 'options': []}
101
+ if tag == 'ATTRIBUTE-DEFINITION-ENUMERATION':
102
+ ref = el.find('.//r:DATATYPE-DEFINITION-ENUMERATION-REF', ns)
103
+ if ref is not None:
104
+ entry['options'] = enum_values_by_datatype.get(ref.text, [])
105
+ definitions[ident] = entry
106
+ return definitions
107
+
108
+ def _ids_by_name(self, long_name):
109
+ return {ident for ident, meta in self._definitions.items() if meta['long_name'] == long_name}
110
+
111
+ def _status_options_list(self):
112
+ for ident in self._ids_by_name(self.STATUS_ATTR):
113
+ return self._definitions[ident]['options']
114
+ return []
115
+
116
+ @staticmethod
117
+ def _xhtml_text(the_value_el):
118
+ if the_value_el is None:
119
+ return ''
120
+ return ''.join(the_value_el.itertext()).strip()
121
+
122
+ @staticmethod
123
+ def _collect_images(the_value_el):
124
+ """<object type="image/..." data="ruta"> dentro del XHTML,
125
+ incluidos los anidados (p.ej. un .doc con una vista previa PNG
126
+ como fallback)."""
127
+ if the_value_el is None:
128
+ return []
129
+ images = []
130
+ for obj in the_value_el.iter(f'{{{XHTML_NS}}}object'):
131
+ obj_type = obj.attrib.get('type', '')
132
+ data = obj.attrib.get('data')
133
+ if data and obj_type.startswith('image/'):
134
+ images.append(data)
135
+ return images
136
+
137
+ def _parse_requirements(self):
138
+ ns = self._ns
139
+ text_ids = self._ids_by_name(self.TEXT_ATTR)
140
+ chapter_ids = self._ids_by_name(self.CHAPTER_ATTR)
141
+ comment_ids = self._ids_by_name(self.COMMENT_ATTR)
142
+ status_ids = self._ids_by_name(self.STATUS_ATTR)
143
+
144
+ requirements = []
145
+ by_id = {}
146
+ for spec_object in self._root.findall('.//r:SPEC-OBJECT', ns):
147
+ identifier = spec_object.attrib.get('IDENTIFIER', '')
148
+ values = spec_object.find('r:VALUES', ns)
149
+ if values is None:
150
+ continue
151
+
152
+ text = ''
153
+ chapter = ''
154
+ comment = ''
155
+ status = ''
156
+ images = []
157
+
158
+ for xhtml_val in values.findall('r:ATTRIBUTE-VALUE-XHTML', ns):
159
+ ref = xhtml_val.find('.//r:ATTRIBUTE-DEFINITION-XHTML-REF', ns)
160
+ if ref is None or not ref.text:
161
+ continue
162
+ the_value = xhtml_val.find('r:THE-VALUE', ns)
163
+ plain = self._xhtml_text(the_value)
164
+ images.extend(self._collect_images(the_value))
165
+ if ref.text in text_ids:
166
+ text = plain
167
+ elif ref.text in chapter_ids:
168
+ chapter = plain
169
+ elif ref.text in comment_ids:
170
+ comment = plain
171
+
172
+ for enum_val in values.findall('r:ATTRIBUTE-VALUE-ENUMERATION', ns):
173
+ ref = enum_val.find('.//r:ATTRIBUTE-DEFINITION-ENUMERATION-REF', ns)
174
+ if ref is None or ref.text not in status_ids:
175
+ continue
176
+ enum_ref = enum_val.find('.//r:ENUM-VALUE-REF', ns)
177
+ if enum_ref is not None:
178
+ for opt_id, opt_name in self._status_options:
179
+ if opt_id == enum_ref.text:
180
+ status = opt_name
181
+ break
182
+
183
+ requirement = Requirement(
184
+ id=identifier,
185
+ text=text or chapter,
186
+ is_chapter=bool(chapter and not text),
187
+ comment=comment,
188
+ status=status,
189
+ images=images,
190
+ )
191
+ requirements.append(requirement)
192
+ by_id[identifier] = requirement
193
+
194
+ return requirements, by_id
195
+
196
+ # -- API pública ------------------------------------------------
197
+
198
+ @property
199
+ def requirements(self) -> list[Requirement]:
200
+ return self._requirements
201
+
202
+ @property
203
+ def status_options(self) -> list[str]:
204
+ return [name for _opt_id, name in self._status_options]
205
+
206
+ def get(self, identifier: str) -> Requirement | None:
207
+ return self._by_id.get(identifier)
208
+
209
+ def __len__(self):
210
+ return len(self._requirements)
211
+
212
+ def __iter__(self):
213
+ return iter(self._requirements)
214
+
215
+ def update(self, identifier: str, comment: str | None = None, status: str | None = None):
216
+ """Actualiza el comentario y/o el estado de un requisito.
217
+
218
+ `None` deja el campo tal cual está; usa '' para vaciarlo.
219
+ """
220
+ requirement = self._by_id.get(identifier)
221
+ if requirement is None:
222
+ raise ReqifError(f'No se encontró el requisito {identifier}')
223
+
224
+ spec_object = self._find_spec_object(identifier)
225
+ values = spec_object.find('r:VALUES', self._ns)
226
+ if values is None:
227
+ values = ET.SubElement(spec_object, self._qn('VALUES'))
228
+
229
+ if comment is not None:
230
+ comment_def_id = next(iter(self._ids_by_name(self.COMMENT_ATTR)), None)
231
+ if comment_def_id:
232
+ self._set_xhtml_value(values, comment_def_id, comment)
233
+ requirement.comment = comment.strip()
234
+
235
+ if status is not None:
236
+ status_def_id = next(iter(self._ids_by_name(self.STATUS_ATTR)), None)
237
+ if status_def_id:
238
+ status_ids_by_name = {name: opt_id for opt_id, name in self._status_options}
239
+ self._set_enumeration_value(values, status_def_id, status, status_ids_by_name)
240
+ requirement.status = status.strip()
241
+
242
+ def update_many(self, updates: Iterable[tuple[str, str | None, str | None]]) -> int:
243
+ """updates: iterable de (identifier, comment, status). Devuelve
244
+ cuántas filas coincidieron con un requisito existente."""
245
+ applied = 0
246
+ for identifier, comment, status in updates:
247
+ if identifier not in self._by_id:
248
+ continue
249
+ self.update(identifier, comment=comment, status=status)
250
+ applied += 1
251
+ return applied
252
+
253
+ def _find_spec_object(self, identifier):
254
+ for candidate in self._root.findall('.//r:SPEC-OBJECT', self._ns):
255
+ if candidate.attrib.get('IDENTIFIER') == identifier:
256
+ return candidate
257
+ raise ReqifError(f'No se encontró el SPEC-OBJECT {identifier}')
258
+
259
+ def _set_xhtml_value(self, values, def_id, text):
260
+ ns = self._ns
261
+ target = None
262
+ for xhtml_val in values.findall('r:ATTRIBUTE-VALUE-XHTML', ns):
263
+ ref = xhtml_val.find('.//r:ATTRIBUTE-DEFINITION-XHTML-REF', ns)
264
+ if ref is not None and ref.text == def_id:
265
+ target = xhtml_val
266
+ break
267
+
268
+ text = (text or '').strip()
269
+
270
+ if target is None:
271
+ if not text:
272
+ return
273
+ target = ET.SubElement(values, self._qn('ATTRIBUTE-VALUE-XHTML'))
274
+ definition = ET.SubElement(target, self._qn('DEFINITION'))
275
+ ref_el = ET.SubElement(definition, self._qn('ATTRIBUTE-DEFINITION-XHTML-REF'))
276
+ ref_el.text = def_id
277
+
278
+ old_value = target.find('r:THE-VALUE', ns)
279
+ if old_value is not None:
280
+ target.remove(old_value)
281
+
282
+ the_value = ET.SubElement(target, self._qn('THE-VALUE'))
283
+ div = ET.SubElement(the_value, f'{{{XHTML_NS}}}div')
284
+ div.text = text
285
+
286
+ def _set_enumeration_value(self, values, def_id, status_name, status_ids_by_name):
287
+ ns = self._ns
288
+ target = None
289
+ for enum_val in values.findall('r:ATTRIBUTE-VALUE-ENUMERATION', ns):
290
+ ref = enum_val.find('.//r:ATTRIBUTE-DEFINITION-ENUMERATION-REF', ns)
291
+ if ref is not None and ref.text == def_id:
292
+ target = enum_val
293
+ break
294
+
295
+ enum_id = status_ids_by_name.get((status_name or '').strip())
296
+
297
+ if not enum_id:
298
+ if target is not None:
299
+ values.remove(target)
300
+ return
301
+
302
+ if target is None:
303
+ target = ET.SubElement(values, self._qn('ATTRIBUTE-VALUE-ENUMERATION'))
304
+ definition = ET.SubElement(target, self._qn('DEFINITION'))
305
+ ref_el = ET.SubElement(definition, self._qn('ATTRIBUTE-DEFINITION-ENUMERATION-REF'))
306
+ ref_el.text = def_id
307
+
308
+ old_values = target.find('r:VALUES', ns)
309
+ if old_values is not None:
310
+ target.remove(old_values)
311
+
312
+ values_el = ET.SubElement(target, self._qn('VALUES'))
313
+ ref_val = ET.SubElement(values_el, self._qn('ENUM-VALUE-REF'))
314
+ ref_val.text = enum_id
315
+
316
+ # -- persistencia -------------------------------------------------
317
+
318
+ def save(self, destination=None):
319
+ """Escribe el XML actual en `destination` (ruta u objeto tipo
320
+ fichero), o en self.path si no se indica ninguno."""
321
+ target = destination if destination is not None else self.path
322
+ if target is None:
323
+ raise ReqifError('No hay ruta de destino: pasa una a save() o crea el Reqif desde una ruta.')
324
+ self._tree.write(target, encoding='utf-8', xml_declaration=True)
325
+
326
+ # -- Excel ----------------------------------------------------------
327
+
328
+ def to_excel(self, destination):
329
+ """Escribe en `destination` (ruta u objeto tipo fichero) un
330
+ .xlsx con una fila por requisito y un desplegable de validación
331
+ en la columna de estado con las opciones reales del ReqIF."""
332
+ option_names = [name for name in self.status_options if name]
333
+ wrap_top = Alignment(wrap_text=True, vertical='top')
334
+
335
+ workbook = Workbook()
336
+ sheet = workbook.active
337
+ sheet.title = 'Requisitos'
338
+ sheet.append(EXCEL_HEADERS)
339
+ for cell in sheet[1]:
340
+ cell.alignment = Alignment(wrap_text=True, vertical='center')
341
+
342
+ for req in self._requirements:
343
+ sheet.append([req.id, req.text, req.comment, req.status])
344
+ row = sheet.max_row
345
+ for column in ('A', 'B', 'C', 'D'):
346
+ sheet[f'{column}{row}'].alignment = wrap_top
347
+
348
+ sheet.column_dimensions['A'].width = 38
349
+ sheet.column_dimensions['B'].width = 60
350
+ sheet.column_dimensions['C'].width = 45
351
+ sheet.column_dimensions['D'].width = 24
352
+ sheet.freeze_panes = 'A2'
353
+
354
+ if option_names:
355
+ options_sheet = workbook.create_sheet('Opciones')
356
+ options_sheet.append([self.STATUS_ATTR])
357
+ for name in option_names:
358
+ options_sheet.append([name])
359
+ options_sheet.sheet_state = 'hidden'
360
+
361
+ last_row = len(self._requirements) + 1
362
+ last_option_row = len(option_names) + 1
363
+ validation = DataValidation(
364
+ type='list',
365
+ formula1=f"=Opciones!$A$2:$A${last_option_row}",
366
+ allow_blank=True,
367
+ )
368
+ sheet.add_data_validation(validation)
369
+ validation.add(f'D2:D{last_row}')
370
+
371
+ workbook.save(destination)
372
+
373
+ def update_from_excel(self, source) -> int:
374
+ """Lee un .xlsx (con las columnas de to_excel) desde `source`
375
+ (ruta u objeto tipo fichero) y actualiza solo comentario y
376
+ estado de cada fila, localizando el requisito por su ID
377
+ (columna A). Devuelve el número de filas aplicadas."""
378
+ workbook = load_workbook(source, data_only=True)
379
+ sheet = workbook['Requisitos'] if 'Requisitos' in workbook.sheetnames else workbook.active
380
+
381
+ updates = []
382
+ for row in sheet.iter_rows(min_row=2, values_only=True):
383
+ if not row or not row[0]:
384
+ continue
385
+ identifier = str(row[0]).strip()
386
+ comment = row[2] if len(row) > 2 and row[2] is not None else ''
387
+ status = row[3] if len(row) > 3 and row[3] is not None else ''
388
+ updates.append((identifier, str(comment), str(status)))
389
+
390
+ return self.update_many(updates)
@@ -0,0 +1,96 @@
1
+ """Reqifz: apertura, edición y reempaquetado de un .reqifz completo."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import tempfile
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from .reqif import Reqif
10
+
11
+
12
+ class Reqifz:
13
+ """Un .reqifz es un zip con uno o varios .reqif más los ficheros
14
+ adjuntos que referencian (imágenes, documentos...). Esta clase lo
15
+ extrae a un directorio de trabajo, expone cada .reqif como un
16
+ `Reqif`, y permite reempaquetarlo todo de vuelta conservando los
17
+ adjuntos intactos.
18
+
19
+ Se puede usar como gestor de contexto para limpiar automáticamente
20
+ el directorio de trabajo cuando este se ha creado internamente:
21
+
22
+ with Reqifz("documento.reqifz") as pack:
23
+ pack.documents[0].update("_abc123", status="akzeptiert")
24
+ pack.save("documento_editado.reqifz")
25
+ """
26
+
27
+ def __init__(self, source, work_dir: str | Path | None = None):
28
+ self._owns_work_dir = work_dir is None
29
+ self.work_dir = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix='pyreqif_'))
30
+ self.work_dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ with zipfile.ZipFile(source) as zf:
33
+ zf.extractall(self.work_dir)
34
+
35
+ self._document_paths = sorted(
36
+ p.relative_to(self.work_dir) for p in self.work_dir.rglob('*.reqif')
37
+ )
38
+ self.documents: list[Reqif] = [Reqif(self.work_dir / p) for p in self._document_paths]
39
+
40
+ # -- acceso a los documentos ---------------------------------------
41
+
42
+ def get(self, name_or_index) -> Reqif:
43
+ """Busca un documento por índice, o por nombre (con o sin ruta
44
+ relativa dentro del zip)."""
45
+ if isinstance(name_or_index, int):
46
+ return self.documents[name_or_index]
47
+ for rel_path, doc in zip(self._document_paths, self.documents):
48
+ if rel_path.name == name_or_index or str(rel_path) == name_or_index:
49
+ return doc
50
+ raise KeyError(name_or_index)
51
+
52
+ def __iter__(self):
53
+ return iter(self.documents)
54
+
55
+ def __len__(self):
56
+ return len(self.documents)
57
+
58
+ def document_names(self) -> list[str]:
59
+ return [p.name for p in self._document_paths]
60
+
61
+ # -- adjuntos ------------------------------------------------------
62
+
63
+ def image_path(self, image_ref: str) -> Path:
64
+ """Resuelve una ruta de imagen (tal como aparece en
65
+ Requirement.images) a una ruta absoluta dentro del directorio
66
+ de trabajo."""
67
+ return self.work_dir / image_ref
68
+
69
+ def read_image(self, image_ref: str) -> bytes:
70
+ return self.image_path(image_ref).read_bytes()
71
+
72
+ # -- persistencia ----------------------------------------------------
73
+
74
+ def save(self, destination):
75
+ """Vuelca los cambios de cada documento y reempaqueta todo el
76
+ directorio de trabajo (documentos + adjuntos) en `destination`
77
+ (ruta u objeto tipo fichero), como .reqifz."""
78
+ for rel_path, doc in zip(self._document_paths, self.documents):
79
+ doc.save(self.work_dir / rel_path)
80
+
81
+ with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as zf:
82
+ for file_path in sorted(self.work_dir.rglob('*')):
83
+ if file_path.is_file():
84
+ zf.write(file_path, file_path.relative_to(self.work_dir))
85
+
86
+ def close(self):
87
+ """Borra el directorio de trabajo, solo si lo creó esta
88
+ instancia (no si se pasó `work_dir` explícitamente)."""
89
+ if self._owns_work_dir and self.work_dir.exists():
90
+ shutil.rmtree(self.work_dir)
91
+
92
+ def __enter__(self):
93
+ return self
94
+
95
+ def __exit__(self, exc_type, exc, tb):
96
+ self.close()
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyreqifz
3
+ Version: 0.1.0
4
+ Summary: Read, edit and repack ReqIF/ReqIFz requirement documents (OMG ReqIF standard)
5
+ Author: Ernesto Avedillo Carretero
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: openpyxl>=3.1.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: build>=1.0; extra == "dev"
14
+ Requires-Dist: twine>=5.0; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # pyreqif
18
+
19
+ Librería Python para leer, editar y reempaquetar documentos [ReqIF](https://www.omg.org/spec/ReqIF/) (`.reqif` / `.reqifz`), el estándar OMG usado habitualmente para intercambiar requisitos entre fabricante y proveedor (p.ej. lastenheft de automoción).
20
+
21
+ Dos clases:
22
+
23
+ - **`Reqif`**: un único fichero `.reqif`. Lee cada requisito (`SPEC-OBJECT`) con su texto, y dos campos configurables pensados para el flujo proveedor: comentario (`Kommentar Lieferant M`, XHTML) y estado (`Status Lieferant M`, enumerado). Permite editarlos, exportar/importar por Excel (con desplegable de validación para el estado) y detecta las imágenes embebidas en el texto de cada requisito.
24
+ - **`Reqifz`**: un `.reqifz` (zip con uno o varios `.reqif` más sus adjuntos). Extrae a un directorio de trabajo, expone cada `.reqif` como un `Reqif`, y reempaqueta todo de vuelta conservando los adjuntos intactos.
25
+
26
+ ## Instalación
27
+
28
+ ```bash
29
+ pip install pyreqif
30
+ ```
31
+
32
+ ## Uso básico
33
+
34
+ ```python
35
+ from pyreqif import Reqifz
36
+
37
+ with Reqifz("lastenheft.reqifz") as pack:
38
+ for doc in pack:
39
+ print(doc, "->", len(doc), "requisitos")
40
+
41
+ doc = pack.get(0) # o pack.get("nombre_del_fichero.reqif")
42
+
43
+ # leer
44
+ req = doc.get("_a1b2c3...")
45
+ print(req.text, req.comment, req.status, req.images)
46
+
47
+ # editar (None deja el campo igual, "" lo vacía)
48
+ doc.update("_a1b2c3...", comment="Aceptado, sin cambios.", status="akzeptiert")
49
+
50
+ # exportar/importar por Excel
51
+ doc.to_excel("requisitos.xlsx")
52
+ doc.update_from_excel("requisitos_revisado.xlsx")
53
+
54
+ # volver a empaquetar con los cambios
55
+ pack.save("lastenheft_editado.reqifz")
56
+ ```
57
+
58
+ Un `.reqif` suelto (sin comprimir) se usa igual, sin pasar por `Reqifz`:
59
+
60
+ ```python
61
+ from pyreqif import Reqif
62
+
63
+ doc = Reqif("documento.reqif")
64
+ doc.update("_a1b2c3...", status="Klärungsbedarf")
65
+ doc.save("documento_editado.reqif")
66
+ ```
67
+
68
+ ## Nombres de atributo
69
+
70
+ Por defecto se usan los nombres estándar de ReqIF para el texto (`ReqIF.Text` / `ReqIF.ChapterName` como respaldo) y los nombres de campo de este flujo concreto de proveedor (`Kommentar Lieferant M` / `Status Lieferant M`). Si tu documento usa otros nombres, crea una subclase:
71
+
72
+ ```python
73
+ from pyreqif import Reqif
74
+
75
+ class MiReqif(Reqif):
76
+ COMMENT_ATTR = "Supplier Comment"
77
+ STATUS_ATTR = "Supplier Status"
78
+ ```
79
+
80
+ ## Desarrollo
81
+
82
+ ```bash
83
+ uv venv
84
+ uv pip install -e ".[dev]"
85
+ uv run pytest
86
+ ```
87
+
88
+ ## Licencia
89
+
90
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pyreqif/__init__.py
5
+ src/pyreqif/py.typed
6
+ src/pyreqif/reqif.py
7
+ src/pyreqif/reqifz.py
8
+ src/pyreqifz.egg-info/PKG-INFO
9
+ src/pyreqifz.egg-info/SOURCES.txt
10
+ src/pyreqifz.egg-info/dependency_links.txt
11
+ src/pyreqifz.egg-info/requires.txt
12
+ src/pyreqifz.egg-info/top_level.txt
13
+ tests/test_reqif.py
14
+ tests/test_reqifz.py
@@ -0,0 +1,6 @@
1
+ openpyxl>=3.1.0
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ build>=1.0
6
+ twine>=5.0
@@ -0,0 +1 @@
1
+ pyreqif
@@ -0,0 +1,119 @@
1
+ from openpyxl import load_workbook
2
+
3
+ from pyreqif import Reqif, ReqifError
4
+
5
+
6
+ def test_parses_requirements_and_chapters(sample_reqif_path):
7
+ doc = Reqif(sample_reqif_path)
8
+
9
+ assert len(doc) == 3
10
+ chapter = doc.get('_chapter-1')
11
+ assert chapter.is_chapter is True
12
+ assert chapter.text == 'Introduccion'
13
+
14
+ req1 = doc.get('_req-1')
15
+ assert 'arrancar en menos de 2 segundos' in req1.text
16
+ assert req1.images == ['attachments/img1.png']
17
+
18
+ req2 = doc.get('_req-2')
19
+ assert req2.comment == 'Ya validado en pruebas de carga.'
20
+ assert req2.status == 'akzeptiert'
21
+
22
+
23
+ def test_status_options_come_from_the_enumeration_datatype(sample_reqif_path):
24
+ doc = Reqif(sample_reqif_path)
25
+ assert doc.status_options == ['akzeptiert', 'Klaerungsbedarf', 'nicht akzeptiert']
26
+
27
+
28
+ def test_update_unknown_requirement_raises(sample_reqif_path):
29
+ doc = Reqif(sample_reqif_path)
30
+ try:
31
+ doc.update('_does-not-exist', comment='x')
32
+ assert False, 'expected ReqifError'
33
+ except ReqifError:
34
+ pass
35
+
36
+
37
+ def test_update_and_save_roundtrip(sample_reqif_path, tmp_path):
38
+ doc = Reqif(sample_reqif_path)
39
+ doc.update('_req-1', comment='Comentario nuevo äöü', status='Klaerungsbedarf')
40
+
41
+ out_path = tmp_path / 'edited.reqif'
42
+ doc.save(out_path)
43
+
44
+ reloaded = Reqif(out_path)
45
+ req1 = reloaded.get('_req-1')
46
+ assert req1.comment == 'Comentario nuevo äöü'
47
+ assert req1.status == 'Klaerungsbedarf'
48
+ # el texto original no se ha tocado
49
+ assert 'arrancar en menos de 2 segundos' in req1.text
50
+
51
+
52
+ def test_update_none_leaves_field_untouched(sample_reqif_path):
53
+ doc = Reqif(sample_reqif_path)
54
+ doc.update('_req-2', comment=None, status='nicht akzeptiert')
55
+
56
+ req2 = doc.get('_req-2')
57
+ assert req2.comment == 'Ya validado en pruebas de carga.'
58
+ assert req2.status == 'nicht akzeptiert'
59
+
60
+
61
+ def test_update_empty_string_clears_field(sample_reqif_path):
62
+ doc = Reqif(sample_reqif_path)
63
+ doc.update('_req-2', status='')
64
+
65
+ req2 = doc.get('_req-2')
66
+ assert req2.status == ''
67
+
68
+
69
+ def test_update_many_returns_count_of_matched_rows(sample_reqif_path):
70
+ doc = Reqif(sample_reqif_path)
71
+ applied = doc.update_many([
72
+ ('_req-1', 'c1', 'akzeptiert'),
73
+ ('_does-not-exist', 'c2', 'akzeptiert'),
74
+ ])
75
+ assert applied == 1
76
+ assert doc.get('_req-1').comment == 'c1'
77
+
78
+
79
+ def test_to_excel_has_data_validation_with_status_options(sample_reqif_path, tmp_path):
80
+ doc = Reqif(sample_reqif_path)
81
+ xlsx_path = tmp_path / 'out.xlsx'
82
+ doc.to_excel(xlsx_path)
83
+
84
+ workbook = load_workbook(xlsx_path)
85
+ assert workbook.sheetnames == ['Requisitos', 'Opciones']
86
+
87
+ sheet = workbook['Requisitos']
88
+ assert [c.value for c in sheet[1]] == ['ID', 'Texto', 'Kommentar Lieferant M', 'Status Lieferant M']
89
+ assert sheet.max_row == len(doc) + 1
90
+
91
+ options = [c[0].value for c in workbook['Opciones'].iter_rows(min_row=2)]
92
+ assert options == doc.status_options
93
+
94
+ validations = list(sheet.data_validations.dataValidation)
95
+ assert len(validations) == 1
96
+ assert 'Opciones' in validations[0].formula1
97
+
98
+
99
+ def test_update_from_excel_applies_only_comment_and_status(sample_reqif_path, tmp_path):
100
+ doc = Reqif(sample_reqif_path)
101
+ xlsx_path = tmp_path / 'roundtrip.xlsx'
102
+ doc.to_excel(xlsx_path)
103
+
104
+ workbook = load_workbook(xlsx_path)
105
+ sheet = workbook['Requisitos']
106
+ # fila del _req-1: columna A=ID, C=comentario, D=estado
107
+ for row in sheet.iter_rows(min_row=2):
108
+ if row[0].value == '_req-1':
109
+ row[2].value = 'Actualizado desde Excel'
110
+ row[3].value = 'nicht akzeptiert'
111
+ workbook.save(xlsx_path)
112
+
113
+ updated = doc.update_from_excel(xlsx_path)
114
+ assert updated == len(doc)
115
+
116
+ req1 = doc.get('_req-1')
117
+ assert req1.comment == 'Actualizado desde Excel'
118
+ assert req1.status == 'nicht akzeptiert'
119
+ assert 'arrancar en menos de 2 segundos' in req1.text
@@ -0,0 +1,63 @@
1
+ import zipfile
2
+
3
+ from pyreqif import Reqifz
4
+
5
+
6
+ def test_extracts_and_parses_documents(sample_reqifz_path):
7
+ with Reqifz(sample_reqifz_path) as pack:
8
+ assert pack.document_names() == ['sample.reqif']
9
+ assert len(pack) == 1
10
+
11
+ doc = pack.get(0)
12
+ assert len(doc) == 3
13
+ assert doc.get('_req-2').status == 'akzeptiert'
14
+
15
+ # también se puede pedir por nombre de fichero
16
+ assert pack.get('sample.reqif') is doc
17
+
18
+
19
+ def test_image_path_resolves_inside_work_dir(sample_reqifz_path):
20
+ with Reqifz(sample_reqifz_path) as pack:
21
+ doc = pack.get(0)
22
+ req1 = doc.get('_req-1')
23
+ assert req1.images == ['attachments/img1.png']
24
+
25
+ image_path = pack.image_path(req1.images[0])
26
+ assert image_path.exists()
27
+ assert image_path.read_bytes().startswith(b'\x89PNG')
28
+ assert pack.read_image(req1.images[0]).startswith(b'\x89PNG')
29
+
30
+
31
+ def test_save_repacks_with_edits_and_keeps_attachments(sample_reqifz_path, tmp_path):
32
+ with Reqifz(sample_reqifz_path) as pack:
33
+ doc = pack.get(0)
34
+ doc.update('_req-1', comment='Revisado por el proveedor', status='akzeptiert')
35
+
36
+ out_path = tmp_path / 'edited.reqifz'
37
+ pack.save(out_path)
38
+
39
+ with zipfile.ZipFile(sample_reqifz_path) as zf:
40
+ original_names = set(zf.namelist())
41
+ with zipfile.ZipFile(out_path) as zf:
42
+ edited_names = set(zf.namelist())
43
+
44
+ assert original_names == edited_names # mismos ficheros, nada perdido
45
+
46
+ with Reqifz(out_path) as reopened:
47
+ req1 = reopened.get(0).get('_req-1')
48
+ assert req1.comment == 'Revisado por el proveedor'
49
+ assert req1.status == 'akzeptiert'
50
+
51
+
52
+ def test_work_dir_is_cleaned_up_when_owned_by_reqifz(sample_reqifz_path):
53
+ with Reqifz(sample_reqifz_path) as pack:
54
+ work_dir = pack.work_dir
55
+ assert work_dir.exists()
56
+ assert not work_dir.exists()
57
+
58
+
59
+ def test_explicit_work_dir_is_not_deleted_on_close(sample_reqifz_path, tmp_path):
60
+ work_dir = tmp_path / 'kept'
61
+ with Reqifz(sample_reqifz_path, work_dir=work_dir) as pack:
62
+ assert pack.work_dir == work_dir
63
+ assert work_dir.exists()