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 +21 -0
- pyreqifz-0.1.0/PKG-INFO +90 -0
- pyreqifz-0.1.0/README.md +74 -0
- pyreqifz-0.1.0/pyproject.toml +30 -0
- pyreqifz-0.1.0/setup.cfg +4 -0
- pyreqifz-0.1.0/src/pyreqif/__init__.py +4 -0
- pyreqifz-0.1.0/src/pyreqif/py.typed +0 -0
- pyreqifz-0.1.0/src/pyreqif/reqif.py +390 -0
- pyreqifz-0.1.0/src/pyreqif/reqifz.py +96 -0
- pyreqifz-0.1.0/src/pyreqifz.egg-info/PKG-INFO +90 -0
- pyreqifz-0.1.0/src/pyreqifz.egg-info/SOURCES.txt +14 -0
- pyreqifz-0.1.0/src/pyreqifz.egg-info/dependency_links.txt +1 -0
- pyreqifz-0.1.0/src/pyreqifz.egg-info/requires.txt +6 -0
- pyreqifz-0.1.0/src/pyreqifz.egg-info/top_level.txt +1 -0
- pyreqifz-0.1.0/tests/test_reqif.py +119 -0
- pyreqifz-0.1.0/tests/test_reqifz.py +63 -0
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.
|
pyreqifz-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
pyreqifz-0.1.0/README.md
ADDED
|
@@ -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"]
|
pyreqifz-0.1.0/setup.cfg
ADDED
|
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 @@
|
|
|
1
|
+
|
|
@@ -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()
|