• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aas-core-works / aas-core-codegen / 25155643253

30 Apr 2026 08:32AM UTC coverage: 83.973% (-0.03%) from 83.998%
25155643253

push

github

web-flow
Infer schema constraints for values (#618)

So far, we inferred the constraints on the class properties. As we want
to support lists of primitives, lists of constrained primitives *etc.*,
we need to infer the constraints at each atomic value of a type
annotation, not only at the property level.

We adapt the inference in this change to achieve that. It caused only
minor changes in the generated code -- since we do not limit ourselves
to properties only, and need a more general logic, some of the generated
artefacts had to slightly change.

378 of 430 new or added lines in 6 files covered. (87.91%)

13 existing lines in 3 files now uncovered.

29310 of 34904 relevant lines covered (83.97%)

3.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.44
/aas_core_codegen/xsd/main.py
1
"""Generate XML Schema Definition (XSD) corresponding to the meta-model."""
2

3
import re
4✔
4

5
# noinspection PyUnresolvedReferences
6
import xml.dom.minidom
4✔
7
import xml.etree.ElementTree as ET
4✔
8
from typing import TextIO, MutableMapping, Optional, Tuple, List, Any, Mapping
4✔
9

10
import greenery
4✔
11
from icontract import ensure, require
4✔
12

13
import aas_core_codegen.xsd
4✔
14
from aas_core_codegen import (
4✔
15
    naming,
16
    specific_implementations,
17
    intermediate,
18
    run,
19
    infer_for_schema,
20
)
21
from aas_core_codegen.common import Error, assert_never, Identifier
4✔
22
from aas_core_codegen.parse import retree as parse_retree
4✔
23
from aas_core_codegen.xsd import naming as xsd_naming
4✔
24

25
assert aas_core_codegen.xsd.__doc__ == __doc__
4✔
26

27

28
def _define_for_enumeration(enumeration: intermediate.Enumeration) -> List[ET.Element]:
4✔
29
    """
30
    Generate the definitions for an ``enumeration``.
31
    The root element is to be *extended* with the resulting list.
32
    """
33
    restriction = ET.Element("xs:restriction", {"base": "xs:string"})
4✔
34
    for literal in enumeration.literals:
4✔
35
        restriction.append(ET.Element("xs:enumeration", {"value": literal.value}))
4✔
36

37
    element = ET.Element(
4✔
38
        "xs:simpleType", {"name": xsd_naming.type_name(enumeration.name)}
39
    )
40
    element.append(restriction)
4✔
41

42
    return [element]
4✔
43

44

45
_PRIMITIVE_MAP = {
4✔
46
    intermediate.PrimitiveType.BOOL: "xs:boolean",
47
    intermediate.PrimitiveType.INT: "xs:long",
48
    intermediate.PrimitiveType.FLOAT: "xs:double",
49
    intermediate.PrimitiveType.STR: "xs:string",
50
    intermediate.PrimitiveType.BYTEARRAY: "xs:base64Binary",
51
}
52
assert all(literal in _PRIMITIVE_MAP for literal in intermediate.PrimitiveType)
4✔
53

54
# noinspection RegExpSimplifiable
55
_ESCAPE_BACKSLASH_X_RE = re.compile(r"\\x([a-fA-f0-9]{2})")
4✔
56

57

58
def _undo_escaping_backslash_x_in_pattern(pattern: str) -> str:
4✔
59
    """
60
    Undo the escaping of `\\x??` in the ``pattern``.
61

62
    This is necessary since XML Schema Validators do not know how to handle such escape
63
    sequences in the patterns and need the verbatim characters.
64
    """
65
    parts = []  # type: List[str]
4✔
66
    cursor = None  # type: Optional[int]
4✔
67
    for mtch in re.finditer(_ESCAPE_BACKSLASH_X_RE, pattern):
4✔
68
        if cursor is None:
4✔
69
            parts.append(pattern[: mtch.start()])
4✔
70
        else:
71
            parts.append(pattern[cursor : mtch.start()])
4✔
72

73
        ascii_code = int(mtch.group(1), base=16)
4✔
74
        character = chr(ascii_code)
4✔
75
        parts.append(character)
4✔
76
        cursor = mtch.end()
4✔
77

78
    if cursor is None:
4✔
79
        parts.append(pattern)
4✔
80
    else:
81
        if cursor < len(pattern):
4✔
82
            parts.append(pattern[cursor:])
4✔
83

84
    return "".join(parts)
4✔
85

86

87
# noinspection RegExpSimplifiable
88
_ESCAPE_BACKSLASH_X_U_U_RE = re.compile(
4✔
89
    r"(\\x([a-fA-f0-9]{2})|\\u([a-fA-f0-9]{4})|\\U([a-fA-f0-9]{8}))"
90
)
91

92

93
def _undo_escaping_backslash_x_u_and_U_in_pattern(pattern: str) -> str:
4✔
94
    """
95
    Undo the escaping of ``\\x??``, ``\\u????`` and ``\\U????????`` in the ``pattern``.
96

97
    This is necessary since Greenery does not know how to handle such escape
98
    sequences in the patterns and need the verbatim characters.
99
    """
100
    parts = []  # type: List[str]
×
101
    cursor = None  # type: Optional[int]
×
102
    for mtch in re.finditer(_ESCAPE_BACKSLASH_X_U_U_RE, pattern):
×
103
        if cursor is None:
×
104
            parts.append(pattern[: mtch.start()])
×
105
        else:
106
            parts.append(pattern[cursor : mtch.start()])
×
107

108
        substring = mtch.group(0)
×
109
        assert len(substring) > 2
×
110
        assert substring[0] == "\\"
×
111

112
        hex_code = substring[2:]
×
113
        code_point = int(hex_code, base=16)
×
114
        character = chr(code_point)
×
115
        parts.append(character)
×
116
        cursor = mtch.end()
×
117

118
    if cursor is None:
×
119
        parts.append(pattern)
×
120
    else:
121
        if cursor < len(pattern):
×
122
            parts.append(pattern[cursor:])
×
123

124
    return "".join(parts)
×
125

126

127
class _AnchorRemover(parse_retree.PassThroughVisitor):
4✔
128
    """
129
    Remove anchors from a regex in-place.
130

131
    We need to remove the anchors (``^``, ``$``) since patterns in the XSD are always
132
    anchored.
133

134
    This is necessary since otherwise the schema validation fails.
135
    See: https://stackoverflow.com/questions/4367914/regular-expression-in-xml-schema-definition-fails
136
    """
137

138
    def visit_concatenation(self, node: parse_retree.Concatenation) -> None:
4✔
139
        """Visit the ``concatenation``."""
140
        new_concatenants = []  # type: List[parse_retree.Term]
4✔
141
        for concatenant in node.concatenants:
4✔
142
            if not (
4✔
143
                isinstance(concatenant.value, parse_retree.Symbol)
144
                and concatenant.value.kind
145
                in (parse_retree.SymbolKind.START, parse_retree.SymbolKind.END)
146
            ):
147
                new_concatenants.append(concatenant)
4✔
148

149
        node.concatenants = new_concatenants
4✔
150
        for concatenant in new_concatenants:
4✔
151
            self.visit(concatenant)
4✔
152

153

154
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
155
def _translate_pattern(pattern: str) -> Tuple[Optional[str], Optional[str]]:
4✔
156
    """Translate the pattern to obtain the equivalent in XSD."""
157
    pattern = _undo_escaping_backslash_x_in_pattern(pattern)
4✔
158

159
    parsed, error = parse_retree.parse(values=[pattern])
4✔
160
    if error is not None:
4✔
161
        regex_line, pointer_line = parse_retree.render_pointer(error.cursor)
×
162
        return None, f"{error.message}\n{regex_line}\n{pointer_line}"
×
163
    assert parsed is not None
4✔
164

165
    remover = _AnchorRemover()
4✔
166
    remover.visit(parsed)
4✔
167

168
    values = parse_retree.render(regex=parsed)
4✔
169
    parts = []  # type: List[str]
4✔
170
    for value in values:
4✔
171
        assert isinstance(value, str), (
4✔
172
            "Only strings expected when rendering a pattern "
173
            "supplied originally as a string"
174
        )
175
        parts.append(value)
4✔
176

177
    return "".join(parts), None
4✔
178

179

180
def _generate_xs_restriction(
4✔
181
    base_type: intermediate.PrimitiveType,
182
    constraints: Optional[infer_for_schema.Constraints],
183
) -> Tuple[Optional[ET.Element], Optional[str]]:
184
    """
185
    Generate the ``xs:restriction`` for the given primitive.
186

187
    Return the restriction element (if any length or pattern constraints), or
188
    an error.
189
    """
190
    if constraints is None:
4✔
191
        return None, None
4✔
192

193
    if constraints.len_constraint is None and (
4✔
194
        constraints.patterns is None or (len(constraints.patterns) == 0)
195
    ):
UNCOV
196
        return None, None
×
197

198
    restriction = ET.Element("xs:restriction", {"base": _PRIMITIVE_MAP[base_type]})
4✔
199

200
    # NOTE (mristin):
201
    # We skip the general XML character pattern. It makes greenery
202
    # unbearably slow since it instantiates *each* character in
203
    # the character range. Since XML engines can not deal with the special
204
    # characters any ways, there is no need to include this constraint in
205
    # the XSD pattern restrictions.
206
    patterns_relevant_for_xsd: Optional[List[infer_for_schema.PatternConstraint]] = None
4✔
207

208
    if constraints.patterns is not None:
4✔
209
        patterns_relevant_for_xsd = [
4✔
210
            pattern_constraint
211
            for pattern_constraint in constraints.patterns
212
            if pattern_constraint.pattern
213
            != (
214
                "^[\\x09\\x0A\\x0D\\x20-\\uD7FF\\uE000-\\uFFFD"
215
                "\\U00010000-\\U0010FFFF]*$"
216
            )
217
        ]
218

219
    if patterns_relevant_for_xsd is not None and len(patterns_relevant_for_xsd) > 0:
4✔
220
        translated_pattern: Optional[str]
221

222
        if len(patterns_relevant_for_xsd) == 1:
4✔
223
            translated_pattern, error = _translate_pattern(
4✔
224
                patterns_relevant_for_xsd[0].pattern
225
            )
226
            if error is not None:
4✔
227
                return None, error
×
228
        else:
229
            # NOTE (mristin, 2023-02-27):
230
            # The module ``greenery`` is not annotated with types at the moment.
231
            merger = None  # type: Optional[Any]
×
232
            for pattern_constraint in patterns_relevant_for_xsd:
×
233
                # NOTE (mristin, 2023-02-27):
234
                # Greenery expects the characters to be in Unicode and not escaped.
235
                translated_for_greenery = _undo_escaping_backslash_x_u_and_U_in_pattern(
×
236
                    pattern_constraint.pattern
237
                )
238

239
                try:
×
240
                    parsed = greenery.parse(translated_for_greenery)
×
241
                except Exception as exception:
×
242
                    if translated_for_greenery == pattern_constraint.pattern:
×
243
                        return None, (
×
244
                            f"The greenery failed to parse "
245
                            f"the pattern {translated_for_greenery!r}: {exception}"
246
                        )
247
                    else:
248
                        return None, (
×
249
                            f"The greenery failed to parse "
250
                            f"the pattern {translated_for_greenery!r} "
251
                            f"(which was originally {pattern_constraint.pattern!r}): "
252
                            f"{exception}"
253
                        )
254

255
                if merger is None:
×
256
                    merger = parsed
×
257
                else:
258
                    merger = merger & parsed
×
259

260
            assert merger is not None
×
261

262
            translated_pattern, error = _translate_pattern(str(merger))
×
263
            if error is not None:
×
264
                return None, error
×
265

266
        assert translated_pattern is not None
4✔
267

268
        pattern = ET.Element(
4✔
269
            "xs:pattern",
270
            {"value": translated_pattern},
271
        )
272

273
        restriction.append(pattern)
4✔
274

275
    if constraints.len_constraint is not None:
4✔
276
        if constraints.len_constraint.min_value is not None:
4✔
277
            min_length = ET.Element(
4✔
278
                "xs:minLength", {"value": str(constraints.len_constraint.min_value)}
279
            )
280
            restriction.append(min_length)
4✔
281

282
        if constraints.len_constraint.max_value is not None:
4✔
283
            max_length = ET.Element(
4✔
284
                "xs:maxLength", {"value": str(constraints.len_constraint.max_value)}
285
            )
286
            restriction.append(max_length)
4✔
287

288
    return restriction, None
4✔
289

290

291
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
292
def _generate_xs_element_for_a_primitive_property(
4✔
293
    prop: intermediate.Property, constraints: Optional[infer_for_schema.Constraints]
294
) -> Tuple[Optional[ET.Element], Optional[Error]]:
295
    """
296
    Generate the ``xs:element`` for a primitive property.
297

298
    A primitive property is a property whose type is either a primitive or
299
    a constrained primitive. The reason why we take these two together is that we
300
    in-line the constraints for the constrained primitives.
301

302
    We do not define the constrained primitives separately in the schema in order to
303
    avoid the confusion during comparisons between the XSD and the meta-model in
304
    the book.
305
    """
306
    type_anno = intermediate.beneath_optional(prop.type_annotation)
4✔
307

308
    if (
4✔
309
        isinstance(type_anno, intermediate.OurTypeAnnotation)
310
        and type_anno.our_type.name == "Value_data_type"
311
    ):
312
        # NOTE (mristin, 2022-11-10):
313
        # Please see :py:const:`_EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE`
314
        # for the explanation why we hard-wire the ``Value_data_type`` here
315
        return (
4✔
316
            ET.Element(
317
                "xs:element",
318
                {
319
                    "name": naming.xml_property(prop.name),
320
                    "type": "valueDataType",
321
                },
322
            ),
323
            None,
324
        )
325

326
    # NOTE (mristin):
327
    # Specify the type of the ``type_anno`` here with assert instead of specifying it
328
    # in the pre-condition to help mypy a bit.
329

330
    base_type = intermediate.try_primitive_type(type_anno)
4✔
331

332
    assert (
4✔
333
        base_type is not None
334
    ), f"Expected a primitive or a constrained primitive, but got: {type_anno}"
335

336
    xs_restriction, error = _generate_xs_restriction(
4✔
337
        base_type=base_type, constraints=constraints
338
    )
339
    if error is not None:
4✔
340
        return None, Error(
×
341
            prop.parsed.node,
342
            f"Failed to generate the restriction for property {prop.name}: {error}",
343
        )
344
    # NOTE (mristin, 2022-06-18):
345
    # xs_restriction may be None here if there are no constraints.
346

347
    xs_element: ET.Element
348

349
    if xs_restriction is None:
4✔
350
        xs_element = ET.Element(
4✔
351
            "xs:element",
352
            {
353
                "name": naming.xml_property(prop.name),
354
                "type": _PRIMITIVE_MAP[base_type],
355
            },
356
        )
357
    else:
358
        xs_simple_type = ET.Element("xs:simpleType")
4✔
359
        xs_simple_type.append(xs_restriction)
4✔
360

361
        xs_element = ET.Element("xs:element", {"name": naming.xml_property(prop.name)})
4✔
362
        xs_element.append(xs_simple_type)
4✔
363

364
    return xs_element, None
4✔
365

366

367
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
368
def _generate_xs_element_for_a_list_property(
4✔
369
    prop: intermediate.Property, constraints: Optional[infer_for_schema.Constraints]
370
) -> Tuple[Optional[ET.Element], Optional[Error]]:
371
    """Generate the ``xs:element`` for a list property."""
372
    type_anno = intermediate.beneath_optional(prop.type_annotation)
4✔
373

374
    # NOTE (mristin):
375
    # Specify the ``type_anno`` here with assert instead of specifying it
376
    # in the pre-condition to help mypy a bit.
377
    assert isinstance(type_anno, intermediate.ListTypeAnnotation)
4✔
378

379
    min_occurs = "0"
4✔
380
    max_occurs = "unbounded"
4✔
381
    if constraints is not None:
4✔
382
        if constraints.len_constraint is not None:
4✔
383
            if constraints.len_constraint.min_value is not None:
4✔
384
                min_occurs = str(constraints.len_constraint.min_value)
4✔
385

386
            if constraints.len_constraint.max_value is not None:
4✔
NEW
387
                max_occurs = str(constraints.len_constraint.max_value)
×
388

389
    xs_element: ET.Element
390

391
    if isinstance(type_anno.items, intermediate.PrimitiveTypeAnnotation):
4✔
392
        xs_element_inner = ET.Element(
4✔
393
            "xs:element",
394
            {
395
                # NOTE (mristin):
396
                # We simply pick ``v`` as there is currently no specification for
397
                # the list of primitives in XML.
398
                "name": "v",
399
                "type": _PRIMITIVE_MAP[type_anno.items.a_type],
400
                "minOccurs": min_occurs,
401
                "maxOccurs": max_occurs,
402
            },
403
        )
404
        xs_sequence = ET.Element("xs:sequence")
4✔
405
        xs_sequence.append(xs_element_inner)
4✔
406

407
        xs_complex_type = ET.Element("xs:complexType")
4✔
408
        xs_complex_type.append(xs_sequence)
4✔
409

410
        xs_element = ET.Element("xs:element", {"name": naming.xml_property(prop.name)})
4✔
411
        xs_element.append(xs_complex_type)
4✔
412

413
    elif isinstance(type_anno.items, intermediate.OurTypeAnnotation):
4✔
414
        # NOTE (mristin):
415
        # We need to nest the elements in the tag element to separate them in the
416
        # sequence.
417

418
        our_type = type_anno.items.our_type
4✔
419

420
        if isinstance(our_type, intermediate.Enumeration):
4✔
421
            return None, Error(
×
422
                prop.parsed.node,
423
                f"We do not know how to specify the list of enumerations "
424
                f"for the property {prop.name!r} of {prop.specified_for.name!r} "
425
                f"in the XSD with the type: {type_anno}",
426
            )
427

428
        elif isinstance(our_type, intermediate.ConstrainedPrimitive):
4✔
429
            return None, Error(
×
430
                prop.parsed.node,
431
                f"We do not know how to specify the list of constrained primitives "
432
                f"for the property {prop.name!r} of {prop.specified_for.name!r} "
433
                f"in the XSD with the type: {type_anno}",
434
            )
435

436
        elif isinstance(
4✔
437
            our_type, (intermediate.AbstractClass, intermediate.ConcreteClass)
438
        ):
439
            # NOTE (mristin):
440
            # We need to check for the concrete descendants. If there are no concrete
441
            # descendants, there is no choice group either. Notably, this not only
442
            # applies to concrete classes, but there is no choice group for the abstract
443
            # classes without descendants either.
444
            if len(our_type.concrete_descendants) > 0:
4✔
445
                choice_group_name = xsd_naming.choice_group_name(our_type.name)
4✔
446
                xs_group = ET.Element(
4✔
447
                    "xs:group",
448
                    {
449
                        "ref": choice_group_name,
450
                        "minOccurs": min_occurs,
451
                        "maxOccurs": max_occurs,
452
                    },
453
                )
454

455
                xs_sequence = ET.Element("xs:sequence")
4✔
456
                xs_sequence.append(xs_group)
4✔
457

458
                xs_complex_type = ET.Element("xs:complexType")
4✔
459
                xs_complex_type.append(xs_sequence)
4✔
460

461
                xs_element = ET.Element(
4✔
462
                    "xs:element", {"name": naming.xml_property(prop.name)}
463
                )
464
                xs_element.append(xs_complex_type)
4✔
465
            else:
466
                xs_element_inner = ET.Element(
4✔
467
                    "xs:element",
468
                    {
469
                        "name": naming.xml_class_name(our_type.name),
470
                        "type": xsd_naming.type_name(our_type.name),
471
                        "minOccurs": min_occurs,
472
                        "maxOccurs": max_occurs,
473
                    },
474
                )
475
                xs_sequence = ET.Element("xs:sequence")
4✔
476
                xs_sequence.append(xs_element_inner)
4✔
477

478
                xs_complex_type = ET.Element("xs:complexType")
4✔
479
                xs_complex_type.append(xs_sequence)
4✔
480

481
                xs_element = ET.Element(
4✔
482
                    "xs:element", {"name": naming.xml_property(prop.name)}
483
                )
484
                xs_element.append(xs_complex_type)
4✔
485

486
        elif isinstance(our_type, intermediate.ConstrainedPrimitive):
×
487
            return None, Error(
×
488
                prop.parsed.node,
489
                f"We do not know how to specify the list of constrained primitives "
490
                f"for the property {prop.name!r} of {prop.specified_for.name!r} "
491
                f"in the XSD with the type: {type_anno}",
492
            )
493
        else:
494
            assert_never(our_type)
×
495
    else:
496
        return None, Error(
×
497
            prop.parsed.node,
498
            f"We do not know how to specify the list "
499
            f"for the property {prop.name!r} of {prop.specified_for.name!r} "
500
            f"in the XSD with the type: {type_anno}. "
501
            f"If you need this feature, please contact the developers.",
502
        )
503

504
    return xs_element, None
4✔
505

506

507
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
508
def _generate_xs_element_for_a_property(
4✔
509
    prop: intermediate.Property, constraints: Optional[infer_for_schema.Constraints]
510
) -> Tuple[Optional[ET.Element], Optional[Error]]:
511
    """Generate the definition of an ``xs:element`` for a property."""
512
    type_anno = intermediate.beneath_optional(prop.type_annotation)
4✔
513

514
    xs_element: Optional[ET.Element]
515

516
    if isinstance(type_anno, intermediate.PrimitiveTypeAnnotation):
4✔
517
        xs_element, error = _generate_xs_element_for_a_primitive_property(
4✔
518
            prop=prop, constraints=constraints
519
        )
520
        if error is not None:
4✔
521
            return None, error
×
522
        assert xs_element is not None
4✔
523

524
    elif isinstance(type_anno, intermediate.OurTypeAnnotation):
4✔
525
        our_type = type_anno.our_type
4✔
526

527
        if isinstance(our_type, intermediate.Enumeration):
4✔
528
            xs_element = ET.Element(
4✔
529
                "xs:element",
530
                {
531
                    "name": naming.xml_property(prop.name),
532
                    "type": xsd_naming.type_name(our_type.name),
533
                },
534
            )
535

536
        elif isinstance(our_type, intermediate.ConstrainedPrimitive):
4✔
537
            xs_element, error = _generate_xs_element_for_a_primitive_property(
4✔
538
                prop=prop, constraints=constraints
539
            )
540
            if error is not None:
4✔
541
                return None, error
×
542
            assert xs_element is not None
4✔
543

544
        elif isinstance(
4✔
545
            our_type, (intermediate.AbstractClass, intermediate.ConcreteClass)
546
        ):
547
            # NOTE (mristin, 2022-05-26):
548
            # We generate choices only if there are at least one concrete descendant.
549
            # Otherwise, the choice is not generated. Hence, we need to reference
550
            # a choice only if there is actually one.
551
            #
552
            # This is especially necessary for abstract classes with no descendants
553
            # which we still want to include in the schema. We simply generate an empty
554
            # element in the schema for such abstract classes without descendants.
555
            if len(our_type.concrete_descendants) > 0:
4✔
556
                xs_sequence = ET.Element("xs:sequence")
4✔
557
                xs_sequence.append(
4✔
558
                    ET.Element(
559
                        "xs:group", {"ref": xsd_naming.choice_group_name(our_type.name)}
560
                    )
561
                )
562

563
                xs_complex_type = ET.Element("xs:complexType")
4✔
564
                xs_complex_type.append(xs_sequence)
4✔
565

566
                xs_element = ET.Element(
4✔
567
                    "xs:element", {"name": naming.xml_property(prop.name)}
568
                )
569
                xs_element.append(xs_complex_type)
4✔
570
            else:
571
                xs_element = ET.Element(
4✔
572
                    "xs:element",
573
                    {
574
                        "name": naming.xml_property(prop.name),
575
                        "type": xsd_naming.type_name(our_type.name),
576
                    },
577
                )
578
        else:
579
            assert_never(type_anno.our_type)
×
580

581
    elif isinstance(type_anno, intermediate.ListTypeAnnotation):
4✔
582
        xs_element, error = _generate_xs_element_for_a_list_property(
4✔
583
            prop=prop, constraints=constraints
584
        )
585
        if error is not None:
4✔
586
            return None, error
×
587

588
        assert xs_element is not None
4✔
589
    else:
590
        assert_never(type_anno)
×
591

592
    assert xs_element is not None
4✔
593

594
    if isinstance(prop.type_annotation, intermediate.OptionalTypeAnnotation):
4✔
595
        xs_element.attrib["minOccurs"] = "0"
4✔
596
        xs_element.attrib["maxOccurs"] = "1"
4✔
597

598
    return xs_element, None
4✔
599

600

601
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
602
def _define_properties(
4✔
603
    cls: intermediate.ClassUnion,
604
    constraints_by_class: Mapping[
605
        intermediate.ClassUnion, infer_for_schema.ConstraintsByValue
606
    ],
607
) -> Tuple[Optional[List[ET.Element]], Optional[List[Error]]]:
608
    """Define the properties of the ``cls`` as a sequence of tags."""
609
    sequence = []  # type: List[ET.Element]
4✔
610
    errors = []  # type: List[Error]
4✔
611

612
    constraints_by_value = constraints_by_class[cls]
4✔
613

614
    for prop in cls.properties:
4✔
615
        # NOTE (mristin):
616
        # While we allow our classes to tighten the constraints from the parents, XSD
617
        # does not allow us to *easily* tighten the constraints in a group.
618
        #
619
        # Instead of making it really complicated, we simply ignore the tightening
620
        # of the constraints in the children classes.
621
        #
622
        # If we ever want to implement this feature, we have to define three intermediate
623
        # structures:
624
        # 1) Abstract parent class,
625
        # 2) Restricted class, only tightening the elements with
626
        #    ``<xs:restriction base="...">``, and
627
        # 3) Extended class which includes properties specified only for the class with
628
        #    ``<xs:extension base="...">``.
629

630
        if prop.specified_for is not cls:
4✔
631
            continue
4✔
632

633
        type_anno = intermediate.beneath_optional(prop.type_annotation)
4✔
634

635
        constraints = constraints_by_value.get(type_anno, None)
4✔
636

637
        xs_element, error = _generate_xs_element_for_a_property(
4✔
638
            prop=prop, constraints=constraints
639
        )
640
        if error is not None:
4✔
641
            errors.append(error)
×
642
        else:
643
            assert xs_element is not None
4✔
644
            sequence.append(xs_element)
4✔
645

646
    if len(errors) > 0:
4✔
647
        return None, errors
×
648

649
    return sequence, None
4✔
650

651

652
def _generate_xs_group_for_class(
4✔
653
    cls: intermediate.ClassUnion,
654
    constraints_by_class: Mapping[
655
        intermediate.ClassUnion, infer_for_schema.ConstraintsByValue
656
    ],
657
) -> Tuple[Optional[ET.Element], Optional[Error]]:
658
    """Generate the ``xs:group`` representation of the class properties."""
659
    properties, properties_errors = _define_properties(
4✔
660
        cls=cls, constraints_by_class=constraints_by_class
661
    )
662

663
    if properties_errors is not None:
4✔
664
        return None, Error(
×
665
            cls.parsed.node,
666
            f"Failed to generate xs:group for the class {cls.name!r}",
667
            properties_errors,
668
        )
669

670
    assert properties is not None
4✔
671

672
    xs_sequence = ET.Element("xs:sequence")
4✔
673
    for inheritance in cls.inheritances:
4✔
674
        inheritance_xs_group = ET.Element(
4✔
675
            "xs:group", {"ref": xsd_naming.group_name(inheritance.name)}
676
        )
677
        xs_sequence.append(inheritance_xs_group)
4✔
678

679
    xs_sequence.extend(properties)
4✔
680

681
    xs_group = ET.Element("xs:group", {"name": xsd_naming.group_name(cls.name)})
4✔
682
    xs_group.append(xs_sequence)
4✔
683

684
    return xs_group, None
4✔
685

686

687
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
688
def _define_for_class(
4✔
689
    cls: intermediate.ClassUnion,
690
    constraints_by_class: Mapping[
691
        intermediate.ClassUnion, infer_for_schema.ConstraintsByValue
692
    ],
693
) -> Tuple[Optional[List[ET.Element]], Optional[Error]]:
694
    """
695
    Generate the definitions for the class ``cls``.
696

697
    The root element is to be *extended* with the resulting list.
698
    """
699
    # NOTE (mristin, 2022-03-30):
700
    # We define each set of properties in a group. Then we reference these groups
701
    # among the complex types.
702
    # See: https://stackoverflow.com/questions/1198755/xml-schemas-with-multiple-inheritance
703

704
    xs_group, xs_group_error = _generate_xs_group_for_class(
4✔
705
        cls=cls, constraints_by_class=constraints_by_class
706
    )
707
    if xs_group_error is not None:
4✔
708
        return None, xs_group_error
×
709

710
    assert xs_group is not None
4✔
711

712
    xs_group_ref = ET.Element("xs:group", {"ref": xsd_naming.group_name(cls.name)})
4✔
713

714
    xs_sequence = ET.Element("xs:sequence")
4✔
715
    xs_sequence.append(xs_group_ref)
4✔
716

717
    complex_type = ET.Element(
4✔
718
        "xs:complexType", {"name": xsd_naming.type_name(cls.name)}
719
    )
720
    complex_type.append(xs_sequence)
4✔
721

722
    return [xs_group, complex_type], None
4✔
723

724

725
@require(lambda cls: len(cls.concrete_descendants) > 0)
4✔
726
def _generate_choice_group(cls: intermediate.ClassUnion) -> ET.Element:
4✔
727
    """Generate a group that defines a choice of concrete descendants."""
728
    xs_choice = ET.Element("xs:choice")
4✔
729

730
    if isinstance(cls, intermediate.ConcreteClass):
4✔
731
        xs_choice.append(
4✔
732
            ET.Element(
733
                "xs:element",
734
                {
735
                    "name": naming.xml_class_name(cls.name),
736
                    "type": xsd_naming.type_name(cls.name),
737
                },
738
            )
739
        )
740

741
    for descendant in cls.concrete_descendants:
4✔
742
        xs_choice.append(
4✔
743
            ET.Element(
744
                "xs:element",
745
                {
746
                    "name": naming.xml_class_name(descendant.name),
747
                    "type": xsd_naming.type_name(descendant.name),
748
                },
749
            )
750
        )
751

752
    xs_group = ET.Element("xs:group", {"name": xsd_naming.choice_group_name(cls.name)})
4✔
753
    xs_group.append(xs_choice)
4✔
754
    return xs_group
4✔
755

756

757
_WHITESPACE_RE = re.compile(r"\s+")
4✔
758

759

760
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
761
def _retrieve_implementation_specific_elements(
4✔
762
    cls: intermediate.ClassUnion,
763
    spec_impls: specific_implementations.SpecificImplementations,
764
) -> Tuple[Optional[List[ET.Element]], Optional[List[Error]]]:
765
    """Parse the elements from the implementation-specific snippet."""
766
    implementation_key = specific_implementations.ImplementationKey(f"{cls.name}.xml")
×
767

768
    text = spec_impls.get(implementation_key, None)
×
769
    if text is None:
×
770
        return None, [
×
771
            Error(
772
                cls.parsed.node,
773
                f"The implementation is missing "
774
                f"for the implementation-specific class: {implementation_key}",
775
            )
776
        ]
777

778
    implementation_root: ET.Element
779

780
    try:
×
781
        implementation_root = ET.fromstring(text)
×
782
    except Exception as err:
×
783
        return None, [
×
784
            Error(
785
                cls.parsed.node,
786
                f"Failed to parse the XML out of "
787
                f"the specific implementation {implementation_key}: {err}",
788
            )
789
        ]
790

791
    errors = []  # type: List[Error]
×
792
    for descendant in implementation_root.iter():
×
793
        if descendant.text is not None and not _WHITESPACE_RE.fullmatch(
×
794
            descendant.text
795
        ):
796
            errors.append(
×
797
                Error(
798
                    cls.parsed.node,
799
                    f"Unexpected text "
800
                    f"in the specific implementation {implementation_key} "
801
                    f"in an element with tag {descendant.tag!r}: {descendant.text!r}",
802
                )
803
            )
804
            continue
×
805

806
        if descendant.tail is not None and not _WHITESPACE_RE.fullmatch(
×
807
            descendant.tail
808
        ):
809
            errors.append(
×
810
                Error(
811
                    cls.parsed.node,
812
                    f"Unexpected tail text "
813
                    f"in the specific implementation {implementation_key} "
814
                    f"in an element with tag {descendant.tag!r}: {descendant.tail!r}",
815
                )
816
            )
817
            continue
×
818

819
    if len(errors) > 0:
×
820
        return None, errors
×
821

822
    # Ignore the implementation root since it defines a partial schema
823
    elements = []  # type: List[ET.Element]
×
824
    for child in implementation_root:
×
825
        elements.append(child)
×
826

827
    return elements, None
×
828

829

830
def _sort_by_tags_and_names_in_place(root: ET.Element) -> None:
4✔
831
    """
832
    Sort the children elements by tag and name attribute in place.
833

834
    This makes diffing and searching in the schema a bit easier.
835
    """
836
    groups = []  # type: List[ET.Element]
4✔
837
    simple_types = []  # type: List[ET.Element]
4✔
838
    complex_types = []  # type: List[ET.Element]
4✔
839
    miscellaneous = []  # type: List[ET.Element]
4✔
840
    elements = []  # type: List[ET.Element]
4✔
841

842
    for child in root:
4✔
843
        if child.tag == "xs:group":
4✔
844
            groups.append(child)
4✔
845
        elif child.tag == "xs:simpleType":
4✔
846
            simple_types.append(child)
4✔
847
        elif child.tag == "xs:complexType":
4✔
848
            complex_types.append(child)
4✔
849
        elif child.tag == "xs:element":
4✔
850
            elements.append(child)
×
851
        else:
852
            miscellaneous.append(child)
4✔
853

854
    for element_list in [groups, simple_types, complex_types, miscellaneous, elements]:
4✔
855
        element_list.sort(key=lambda elt: elt.attrib.get("name", ""))
4✔
856

857
    children = groups + simple_types + complex_types + elements + miscellaneous
4✔
858

859
    assert len(children) == len(root)
4✔
860
    root[:] = children
4✔
861

862

863
_EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE = (
4✔
864
    "(mristin, 2022-09-02) "
865
    'We provide an internal data type ``valueDataType`` to correspond to "any XSD '
866
    'atomic type as specified via DataTypeDefXsd". We need this type since we '
867
    "hard-wire ``Value_data_type`` to it. We could have made "
868
    "the class ``Value_data_type``implementation-specific and defined its "
869
    "representation manually as a snippet, including ``valueDataType``.\n\n"
870
    "However, we decided against that. This would be a major hurdle for "
871
    "other code and test data generators (which can treat ``Value_data_type`` "
872
    "simply as string). Therefore, we make the XSD generator "
873
    "a bit more hacky instead of complicating the other generators.\n\n"
874
    "If in the future, for whatever reason, the semantic of ``Value_data_type`` "
875
    "changes (or the type is renamed), be careful to maintain backwards "
876
    "compatibility here! You probably want to distinguish different versions "
877
    "of the meta-model and act accordingly. At that point, it might also make "
878
    "sense to refactor this schema generator to a separate repository, and "
879
    "fix it to a particular range of meta-model versions."
880
)
881

882

883
@ensure(lambda result: (result[0] is not None) ^ (result[1] is not None))
4✔
884
def _generate(
4✔
885
    symbol_table: intermediate.SymbolTable,
886
    spec_impls: specific_implementations.SpecificImplementations,
887
) -> Tuple[Optional[str], Optional[List[Error]]]:
888
    """Generate the XML Schema Definition (XSD) based on the ``symbol_table."""
889
    root_element_key = specific_implementations.ImplementationKey("root_element.xml")
4✔
890

891
    root_element_as_text = spec_impls.get(root_element_key, None)
4✔
892
    if root_element_as_text is None:
4✔
893
        return None, [
×
894
            Error(
895
                None,
896
                f"The implementation snippet for the root element "
897
                f"is missing: {root_element_key}",
898
            )
899
        ]
900

901
    root: ET.Element
902
    try:
4✔
903
        root = ET.fromstring(root_element_as_text)
4✔
904
    except ET.ParseError as err:
×
905
        return None, [
×
906
            Error(
907
                None, f"Failed to parse the root element from {root_element_key}: {err}"
908
            )
909
        ]
910

911
    # NOTE (mristin, 2022-03-30):
912
    # We need to use minidom to extract the ``xmlns`` property as ElementTree removes
913
    # it.
914
    # noinspection PyUnresolvedReferences
915
    minidom_doc = xml.dom.minidom.parseString(root_element_as_text)
4✔
916

917
    if not minidom_doc.documentElement.hasAttribute("xmlns"):
4✔
918
        return None, [
×
919
            Error(
920
                None,
921
                f"The implementation snippet for the root element "
922
                f"is missing the 'xmlns' attribute: {root_element_key}",
923
            )
924
        ]
925

926
    xmlns = minidom_doc.documentElement.getAttribute("xmlns")
4✔
927

928
    if xmlns != symbol_table.meta_model.xml_namespace:
4✔
929
        return None, [
×
930
            Error(
931
                None,
932
                f"The 'xmlns' attribute of the implementation snippet "
933
                f"{root_element_key} for the root element "
934
                f"and the '__xml_namespace__' of the meta-model "
935
                f"do not coincide: "
936
                f"{xmlns!r} != {symbol_table.meta_model.xml_namespace!r}",
937
            )
938
        ]
939

940
    if not minidom_doc.documentElement.hasAttribute("targetNamespace"):
4✔
941
        return None, [
×
942
            Error(
943
                None,
944
                f"The implementation snippet for the root element "
945
                f"is missing the 'targetNamespace' attribute: {root_element_key}",
946
            )
947
        ]
948

949
    target_namespace = minidom_doc.documentElement.getAttribute("targetNamespace")
4✔
950
    if target_namespace != symbol_table.meta_model.xml_namespace:
4✔
951
        return None, [
×
952
            Error(
953
                None,
954
                f"The 'targetNamespace' attribute of the implementation snippet "
955
                f"{root_element_key} for the root element "
956
                f"and the '__xml_namespace__' of the meta-model "
957
                f"do not coincide: "
958
                f"{target_namespace!r} != {symbol_table.meta_model.xml_namespace!r}",
959
            )
960
        ]
961

962
    assert root is not None
4✔
963

964
    errors = []  # type: List[Error]
4✔
965

966
    # NOTE (mristin, 2022-04-09):
967
    # We remove any whitespace tail and text in all the tags, and make sure there is no
968
    # unexpected text anywhere.
969
    for element in root.iter():
4✔
970
        if element.text is not None:
4✔
971
            if _WHITESPACE_RE.fullmatch(element.text):
4✔
972
                element.text = None
4✔
973
            else:
974
                errors.append(
×
975
                    Error(
976
                        None,
977
                        f"Unexpected text in an element with tag {element.tag!r} "
978
                        f"from the snippet {root_element_key!r}: {element.text!r}",
979
                    )
980
                )
981

982
        if element.tail is not None:
4✔
983
            if _WHITESPACE_RE.fullmatch(element.tail):
4✔
984
                element.tail = None
4✔
985
            else:
986
                errors.append(
×
987
                    Error(
988
                        None,
989
                        f"Unexpected tail in an element with tag {element.tag!r} "
990
                        f"from the snippet {root_element_key!r}: {element.tail!r}",
991
                    )
992
                )
993

994
    if len(errors) > 0:
4✔
995
        return None, errors
×
996

997
    constraints_by_class, some_errors = infer_for_schema.infer_constraints_by_class(
4✔
998
        symbol_table=symbol_table
999
    )
1000

1001
    if some_errors is not None:
4✔
1002
        errors.extend(some_errors)
×
1003

1004
    # NOTE (mristin, 2022-11-10):
1005
    # Please see :py:const:`_EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE` for
1006
    # the explanation why we retrieve the ``Value_data_type`` here
1007
    value_data_type_cls = symbol_table.find_our_type(Identifier("Value_data_type"))
4✔
1008

1009
    if value_data_type_cls is None:
4✔
1010
        errors.append(
×
1011
            Error(
1012
                None,
1013
                "XSD generator expected to find our type ``Value_data_type``, but it "
1014
                "was not present in the meta-model.\n\n"
1015
                + _EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE,
1016
            )
1017
        )
1018
    elif not isinstance(value_data_type_cls, intermediate.ConstrainedPrimitive):
4✔
1019
        errors.append(
×
1020
            Error(
1021
                None,
1022
                "XSD generator expected ``Value_data_type`` to be "
1023
                "a constrained primitive,  but got: {type(value_data_type_cls)}.\n\n"
1024
                + _EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE,
1025
            )
1026
        )
1027
    elif value_data_type_cls.constrainee != intermediate.PrimitiveType.STR:
4✔
1028
        errors.append(
×
1029
            Error(
1030
                None,
1031
                f"XSD generator expected ``Value_data_type`` to be a constrained "
1032
                f"primitive of strings, "
1033
                f"but got: {value_data_type_cls.constrainee}.\n\n"
1034
                + _EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE,
1035
            )
1036
        )
1037
    else:
1038
        # Our type ``Value_data_type`` is as expected.
1039
        pass
3✔
1040

1041
    if len(errors) > 0:
4✔
1042
        return None, errors
×
1043

1044
    assert constraints_by_class is not None
4✔
1045

1046
    ids_of_our_types_in_properties = (
4✔
1047
        intermediate.collect_ids_of_our_types_in_properties(symbol_table=symbol_table)
1048
    )
1049

1050
    # region Specify ``valueDataType``
1051

1052
    assert value_data_type_cls is not None
4✔
1053

1054
    value_data_type_element = ET.Element(
4✔
1055
        "xs:simpleType", attrib={"name": "valueDataType"}
1056
    )
1057

1058
    value_data_type_element.append(
4✔
1059
        ET.Element(
1060
            "xs:restriction",
1061
            attrib={"base": "xs:string"},
1062
        )
1063
    )
1064

1065
    root.append(value_data_type_element)
4✔
1066

1067
    # endregion
1068

1069
    for our_type in symbol_table.our_types:
4✔
1070
        if our_type.name == "Value_data_type":
4✔
1071
            # NOTE (mristin, 2022-11-10):
1072
            # Please see :py:const:`_EXPLANATION_ABOUT_WHY_WE_EXPECT_VALUE_DATA_TYPE`
1073
            # for the explanation why we hard-wire the ``Value_data_type`` here
1074
            continue
4✔
1075

1076
        elements: Optional[List[ET.Element]]
1077

1078
        if (
4✔
1079
            isinstance(
1080
                our_type, (intermediate.AbstractClass, intermediate.ConcreteClass)
1081
            )
1082
            and our_type.is_implementation_specific
1083
        ):
1084
            elements, impl_spec_errors = _retrieve_implementation_specific_elements(
×
1085
                cls=our_type, spec_impls=spec_impls
1086
            )
1087
            if impl_spec_errors is not None:
×
1088
                errors.extend(impl_spec_errors)
×
1089
                continue
×
1090

1091
            assert elements is not None
×
1092
        else:
1093
            if isinstance(our_type, intermediate.Enumeration):
4✔
1094
                if id(our_type) not in ids_of_our_types_in_properties:
4✔
1095
                    continue
×
1096

1097
                elements = _define_for_enumeration(enumeration=our_type)
4✔
1098

1099
            elif isinstance(our_type, intermediate.ConstrainedPrimitive):
4✔
1100
                # NOTE (mristin, 2022-03-30):
1101
                # We in-line the constraints from the constrained primitives directly
1102
                # in the properties. We do not want to introduce separate definitions
1103
                # for them as that would make it more difficult for downstream code
1104
                # generators to generate meaningful code.
1105

1106
                continue
4✔
1107

1108
            elif isinstance(
4✔
1109
                our_type, (intermediate.AbstractClass, intermediate.ConcreteClass)
1110
            ):
1111
                elements, definition_error = _define_for_class(
4✔
1112
                    cls=our_type, constraints_by_class=constraints_by_class
1113
                )
1114

1115
                if definition_error is not None:
4✔
1116
                    errors.append(definition_error)
×
1117
                    continue
×
1118

1119
                assert elements is not None
4✔
1120

1121
                if len(our_type.concrete_descendants) > 0:
4✔
1122
                    choice_group = _generate_choice_group(cls=our_type)
4✔
1123
                    elements.append(choice_group)
4✔
1124
            else:
1125
                assert_never(our_type)
×
1126

1127
        assert elements is not None
4✔
1128
        root.extend(elements)
4✔
1129

1130
    if len(errors) > 0:
4✔
1131
        return None, errors
×
1132

1133
    # Tag name 🠒 (name 🠒 element)
1134
    observed_definitions = dict(
4✔
1135
        dict()
1136
    )  # type: MutableMapping[str, MutableMapping[str, ET.Element]]
1137

1138
    for element in root:
4✔
1139
        name = element.attrib.get("name", None)
4✔
1140
        if name is None:
4✔
1141
            continue
×
1142

1143
        observed_for_tag = observed_definitions.get(element.tag, None)
4✔
1144
        if observed_for_tag is None:
4✔
1145
            observed_for_tag = dict()
4✔
1146
            observed_definitions[element.tag] = observed_for_tag
4✔
1147

1148
        observed_element = observed_for_tag.get(name, None)
4✔
1149
        if observed_element is not None:
4✔
1150
            ours = ET.tostring(element, encoding="unicode", method="xml")
×
1151
            theirs = ET.tostring(observed_element, encoding="unicode", method="xml")
×
1152

1153
            errors.append(
×
1154
                Error(
1155
                    None,
1156
                    f"There are conflicting definitions in the schema "
1157
                    f"with the name {name!r}:\n"
1158
                    f"\n"
1159
                    f"{ours}\n"
1160
                    f"\n"
1161
                    f"and\n"
1162
                    f"\n"
1163
                    f"{theirs}",
1164
                )
1165
            )
1166
        else:
1167
            observed_for_tag[name] = element
4✔
1168

1169
    if len(errors) > 0:
4✔
1170
        return None, errors
×
1171

1172
    _sort_by_tags_and_names_in_place(root)
4✔
1173

1174
    # NOTE (mristin, 2022-03-30):
1175
    # For some unknown reason, ElementTree erases the xmlns property of the root
1176
    # element. Therefore, we need to add it here manually.
1177
    root.attrib["xmlns"] = xmlns
4✔
1178

1179
    text = ET.tostring(root, encoding="unicode", method="xml")
4✔
1180

1181
    # NOTE (mristin, 2021-11-23):
1182
    # This approach is slow, but effective. As long as the meta-model is not too big,
1183
    # this should work.
1184
    # noinspection PyUnresolvedReferences
1185
    pretty_text = xml.dom.minidom.parseString(text).toprettyxml(indent="  ")
4✔
1186

1187
    return pretty_text, None
4✔
1188

1189

1190
def execute(context: run.Context, stdout: TextIO, stderr: TextIO) -> int:
4✔
1191
    """Generate the code."""
1192
    code, errors = _generate(
4✔
1193
        symbol_table=context.symbol_table, spec_impls=context.spec_impls
1194
    )
1195

1196
    if errors is not None:
4✔
1197
        run.write_error_report(
×
1198
            message=f"Failed to generate the XML Schema Definition "
1199
            f"based on {context.model_path}",
1200
            errors=[context.lineno_columner.error_message(error) for error in errors],
1201
            stderr=stderr,
1202
        )
1203
        return 1
×
1204

1205
    assert code is not None
4✔
1206

1207
    pth = context.output_dir / "schema.xsd"
4✔
1208
    try:
4✔
1209
        pth.write_text(code, encoding="utf-8")
4✔
1210
    except Exception as exception:
×
1211
        run.write_error_report(
×
1212
            message=f"Failed to write the XML Schema Definition to {pth}",
1213
            errors=[str(exception)],
1214
            stderr=stderr,
1215
        )
1216
        return 1
×
1217

1218
    stdout.write(f"Code generated to: {context.output_dir}\n")
4✔
1219
    return 0
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc