elspais 0.9.1__py3-none-any.whl

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,390 @@
1
+ """
2
+ elspais.core.patterns - Configurable requirement ID pattern matching.
3
+
4
+ Supports multiple ID formats:
5
+ - HHT style: REQ-p00001, REQ-CAL-d00001
6
+ - Type-prefix style: PRD-00001, OPS-00001, DEV-00001
7
+ - Jira style: PROJ-123
8
+ - Named: REQ-UserAuth
9
+ """
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from elspais.core.models import ParsedRequirement
16
+
17
+
18
+ @dataclass
19
+ class PatternConfig:
20
+ """
21
+ Configuration for requirement ID patterns.
22
+
23
+ Attributes:
24
+ id_template: Template string with tokens {prefix}, {associated}, {type}, {id}
25
+ prefix: Base prefix (e.g., "REQ")
26
+ types: Dictionary of type definitions
27
+ id_format: ID format configuration (style, digits, etc.)
28
+ associated: Optional associated repo namespace configuration
29
+ assertions: Optional assertion label format configuration
30
+ """
31
+
32
+ id_template: str
33
+ prefix: str
34
+ types: Dict[str, Dict[str, Any]]
35
+ id_format: Dict[str, Any]
36
+ associated: Optional[Dict[str, Any]] = None
37
+ assertions: Optional[Dict[str, Any]] = None
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: Dict[str, Any]) -> "PatternConfig":
41
+ """Create PatternConfig from configuration dictionary."""
42
+ return cls(
43
+ id_template=data.get("id_template", "{prefix}-{type}{id}"),
44
+ prefix=data.get("prefix", "REQ"),
45
+ types=data.get("types", {}),
46
+ id_format=data.get("id_format", {"style": "numeric", "digits": 5}),
47
+ associated=data.get("associated"),
48
+ assertions=data.get("assertions"),
49
+ )
50
+
51
+ def get_type_by_id(self, type_id: str) -> Optional[Dict[str, Any]]:
52
+ """Get type configuration by type ID."""
53
+ for config in self.types.values():
54
+ if config.get("id") == type_id:
55
+ return config
56
+ return None
57
+
58
+ def get_all_type_ids(self) -> List[str]:
59
+ """Get list of all type IDs."""
60
+ return [config.get("id", "") for config in self.types.values()]
61
+
62
+ def get_assertion_label_pattern(self) -> str:
63
+ """Get regex pattern for assertion labels based on configuration."""
64
+ assertions = self.assertions or {}
65
+ style = assertions.get("label_style", "uppercase")
66
+ zero_pad = assertions.get("zero_pad", False)
67
+
68
+ if style == "uppercase":
69
+ return r"[A-Z]"
70
+ elif style == "numeric":
71
+ if zero_pad:
72
+ return r"[0-9]{2}"
73
+ return r"[0-9]{1,2}"
74
+ elif style == "alphanumeric":
75
+ return r"[0-9A-Z]"
76
+ elif style == "numeric_1based":
77
+ if zero_pad:
78
+ return r"[0-9]{2}"
79
+ return r"[1-9][0-9]?"
80
+ else:
81
+ return r"[A-Z]"
82
+
83
+ def get_assertion_max_count(self) -> int:
84
+ """Get maximum number of assertions allowed."""
85
+ assertions = self.assertions or {}
86
+ style = assertions.get("label_style", "uppercase")
87
+ max_count = assertions.get("max_count")
88
+
89
+ if max_count is not None:
90
+ return int(max_count)
91
+
92
+ # Default max based on style
93
+ if style == "uppercase":
94
+ return 26
95
+ elif style == "numeric":
96
+ return 100
97
+ elif style == "alphanumeric":
98
+ return 36
99
+ elif style == "numeric_1based":
100
+ return 99
101
+ return 26
102
+
103
+
104
+ class PatternValidator:
105
+ """
106
+ Validates and parses requirement IDs against configured patterns.
107
+ """
108
+
109
+ def __init__(self, config: PatternConfig):
110
+ """
111
+ Initialize pattern validator.
112
+
113
+ Args:
114
+ config: Pattern configuration
115
+ """
116
+ self.config = config
117
+ self._regex = self._build_regex()
118
+ self._regex_with_assertion = self._build_regex(include_assertion=True)
119
+ self._assertion_label_regex = re.compile(
120
+ f"^{self.config.get_assertion_label_pattern()}$"
121
+ )
122
+
123
+ def _build_regex(self, include_assertion: bool = False) -> re.Pattern:
124
+ """Build regex pattern from configuration.
125
+
126
+ Args:
127
+ include_assertion: If True, include optional assertion suffix pattern
128
+ """
129
+ template = self.config.id_template
130
+
131
+ # Build type alternatives
132
+ type_ids = self.config.get_all_type_ids()
133
+ type_pattern = "|".join(re.escape(t) for t in type_ids if t)
134
+
135
+ # Build ID pattern based on format
136
+ id_format = self.config.id_format
137
+ style = id_format.get("style", "numeric")
138
+
139
+ if style == "numeric":
140
+ digits = int(id_format.get("digits", 5))
141
+ leading_zeros = id_format.get("leading_zeros", True)
142
+ if digits > 0 and leading_zeros:
143
+ id_pattern = f"\\d{{{digits}}}"
144
+ elif digits > 0:
145
+ id_pattern = f"\\d{{1,{digits}}}"
146
+ else:
147
+ id_pattern = "\\d+"
148
+ elif style == "named":
149
+ pattern = id_format.get("pattern", "[A-Za-z][A-Za-z0-9]+")
150
+ id_pattern = pattern
151
+ elif style == "alphanumeric":
152
+ pattern = id_format.get("pattern", "[A-Z0-9]+")
153
+ id_pattern = pattern
154
+ else:
155
+ id_pattern = "[A-Za-z0-9]+"
156
+
157
+ # Build associated pattern if enabled
158
+ associated_config = self.config.associated or {}
159
+ if associated_config.get("enabled"):
160
+ length = associated_config.get("length", 3)
161
+ sep = re.escape(associated_config.get("separator", "-"))
162
+ if length:
163
+ associated_pattern = f"(?P<associated>[A-Z]{{{length}}}){sep}"
164
+ else:
165
+ associated_pattern = f"(?P<associated>[A-Z]+){sep}"
166
+ else:
167
+ associated_pattern = "(?P<associated>)"
168
+
169
+ # Build full regex from template
170
+ # Replace tokens with regex groups
171
+ pattern = template
172
+ pattern = pattern.replace("{prefix}", f"(?P<prefix>{re.escape(self.config.prefix)})")
173
+
174
+ # Handle associated - it's optional
175
+ if "{associated}" in pattern:
176
+ pattern = pattern.replace("{associated}", f"(?:{associated_pattern})?")
177
+ else:
178
+ pattern = pattern.replace("{associated}", "")
179
+
180
+ if type_pattern:
181
+ pattern = pattern.replace("{type}", f"(?P<type>{type_pattern})")
182
+ else:
183
+ pattern = pattern.replace("{type}", "(?P<type>)")
184
+
185
+ pattern = pattern.replace("{id}", f"(?P<id>{id_pattern})")
186
+
187
+ # Optionally add assertion suffix pattern
188
+ if include_assertion:
189
+ assertion_pattern = self.config.get_assertion_label_pattern()
190
+ pattern = f"{pattern}(?:-(?P<assertion>{assertion_pattern}))?"
191
+
192
+ return re.compile(f"^{pattern}$")
193
+
194
+ def parse(self, id_string: str, allow_assertion: bool = False) -> Optional[ParsedRequirement]:
195
+ """
196
+ Parse a requirement ID string into components.
197
+
198
+ Args:
199
+ id_string: The requirement ID to parse (e.g., "REQ-p00001" or "REQ-p00001-A")
200
+ allow_assertion: If True, allow and parse assertion suffix
201
+
202
+ Returns:
203
+ ParsedRequirement if valid, None if invalid
204
+ """
205
+ regex = self._regex_with_assertion if allow_assertion else self._regex
206
+ match = regex.match(id_string)
207
+ if not match:
208
+ return None
209
+
210
+ groups = match.groupdict()
211
+ return ParsedRequirement(
212
+ full_id=id_string,
213
+ prefix=groups.get("prefix", ""),
214
+ associated=groups.get("associated") or None,
215
+ type_code=groups.get("type", ""),
216
+ number=groups.get("id", ""),
217
+ assertion=groups.get("assertion") or None,
218
+ )
219
+
220
+ def is_valid(self, id_string: str, allow_assertion: bool = False) -> bool:
221
+ """
222
+ Check if an ID string is valid.
223
+
224
+ Args:
225
+ id_string: The requirement ID to validate
226
+ allow_assertion: If True, allow assertion suffix
227
+
228
+ Returns:
229
+ True if valid, False otherwise
230
+ """
231
+ return self.parse(id_string, allow_assertion=allow_assertion) is not None
232
+
233
+ def is_valid_assertion_label(self, label: str) -> bool:
234
+ """
235
+ Check if an assertion label is valid.
236
+
237
+ Args:
238
+ label: The assertion label to validate (e.g., "A", "01")
239
+
240
+ Returns:
241
+ True if valid, False otherwise
242
+ """
243
+ return self._assertion_label_regex.match(label) is not None
244
+
245
+ def format_assertion_label(self, index: int) -> str:
246
+ """
247
+ Format an assertion label from a zero-based index.
248
+
249
+ Args:
250
+ index: Zero-based index (0 = A or 00, 1 = B or 01, etc.)
251
+
252
+ Returns:
253
+ Formatted assertion label
254
+ """
255
+ assertions = self.config.assertions or {}
256
+ style = assertions.get("label_style", "uppercase")
257
+ zero_pad = assertions.get("zero_pad", False)
258
+
259
+ if style == "uppercase":
260
+ if index < 0 or index >= 26:
261
+ raise ValueError(f"Index {index} out of range for uppercase labels (0-25)")
262
+ return chr(ord("A") + index)
263
+ elif style == "numeric":
264
+ if zero_pad:
265
+ return f"{index:02d}"
266
+ return str(index)
267
+ elif style == "alphanumeric":
268
+ if index < 10:
269
+ return str(index)
270
+ elif index < 36:
271
+ return chr(ord("A") + index - 10)
272
+ else:
273
+ raise ValueError(f"Index {index} out of range for alphanumeric labels (0-35)")
274
+ elif style == "numeric_1based":
275
+ if zero_pad:
276
+ return f"{index + 1:02d}"
277
+ return str(index + 1)
278
+ else:
279
+ return chr(ord("A") + index)
280
+
281
+ def parse_assertion_label_index(self, label: str) -> int:
282
+ """
283
+ Parse an assertion label to get its zero-based index.
284
+
285
+ Args:
286
+ label: The assertion label (e.g., "A", "01", "B")
287
+
288
+ Returns:
289
+ Zero-based index
290
+ """
291
+ assertions = self.config.assertions or {}
292
+ style = assertions.get("label_style", "uppercase")
293
+
294
+ if style == "uppercase":
295
+ if len(label) == 1 and label.isupper():
296
+ return ord(label) - ord("A")
297
+ elif style == "numeric":
298
+ return int(label)
299
+ elif style == "alphanumeric":
300
+ if label.isdigit():
301
+ return int(label)
302
+ elif len(label) == 1 and label.isupper():
303
+ return ord(label) - ord("A") + 10
304
+ elif style == "numeric_1based":
305
+ return int(label) - 1
306
+
307
+ raise ValueError(f"Cannot parse assertion label: {label}")
308
+
309
+ def format(
310
+ self, type_code: str, number: int, associated: Optional[str] = None
311
+ ) -> str:
312
+ """
313
+ Format a requirement ID from components.
314
+
315
+ Args:
316
+ type_code: The requirement type code (e.g., "p")
317
+ number: The requirement number
318
+ associated: Optional associated repo code
319
+
320
+ Returns:
321
+ Formatted requirement ID string
322
+ """
323
+ template = self.config.id_template
324
+ id_format = self.config.id_format
325
+
326
+ # Format number
327
+ style = id_format.get("style", "numeric")
328
+ if style == "numeric":
329
+ digits = int(id_format.get("digits", 5))
330
+ leading_zeros = id_format.get("leading_zeros", True)
331
+ if digits > 0 and leading_zeros:
332
+ formatted_number = str(number).zfill(digits)
333
+ else:
334
+ formatted_number = str(number)
335
+ else:
336
+ formatted_number = str(number)
337
+
338
+ # Build result
339
+ result = template
340
+ result = result.replace("{prefix}", self.config.prefix)
341
+
342
+ # Handle associated
343
+ if associated and "{associated}" in result:
344
+ associated_config = self.config.associated or {}
345
+ sep = associated_config.get("separator", "-")
346
+ result = result.replace("{associated}", f"{associated}{sep}")
347
+ else:
348
+ result = result.replace("{associated}", "")
349
+
350
+ result = result.replace("{type}", type_code)
351
+ result = result.replace("{id}", formatted_number)
352
+
353
+ return result
354
+
355
+ def extract_implements_ids(self, implements_str: str) -> List[str]:
356
+ """
357
+ Extract requirement IDs from an Implements field value.
358
+
359
+ Handles formats like:
360
+ - "p00001"
361
+ - "p00001, o00002"
362
+ - "REQ-p00001, REQ-o00002"
363
+ - "CAL-p00001"
364
+
365
+ Args:
366
+ implements_str: The Implements field value
367
+
368
+ Returns:
369
+ List of normalized requirement IDs
370
+ """
371
+ if not implements_str:
372
+ return []
373
+
374
+ # Split by comma
375
+ parts = [p.strip() for p in implements_str.split(",")]
376
+ result = []
377
+
378
+ for part in parts:
379
+ if not part:
380
+ continue
381
+
382
+ # Check if it's a full ID
383
+ if self.is_valid(part):
384
+ result.append(part)
385
+ else:
386
+ # It might be a shortened ID like "p00001" or "CAL-p00001"
387
+ # Just keep the raw value for later resolution
388
+ result.append(part)
389
+
390
+ return result