cognite-neat 0.127.19__py3-none-any.whl → 0.127.21__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.
- cognite/neat/_data_model/exporters/_table_exporter/writer.py +26 -4
- cognite/neat/_data_model/importers/_table_importer/data_classes.py +4 -0
- cognite/neat/_data_model/importers/_table_importer/reader.py +218 -30
- cognite/neat/_data_model/models/dms/_constants.py +1 -0
- cognite/neat/_data_model/models/dms/_data_model.py +2 -1
- cognite/neat/_session/_html/static/issues.js +14 -13
- cognite/neat/_session/_html/templates/issues.html +8 -7
- cognite/neat/_session/_issues.py +5 -0
- cognite/neat/_version.py +1 -1
- {cognite_neat-0.127.19.dist-info → cognite_neat-0.127.21.dist-info}/METADATA +1 -1
- {cognite_neat-0.127.19.dist-info → cognite_neat-0.127.21.dist-info}/RECORD +13 -13
- {cognite_neat-0.127.19.dist-info → cognite_neat-0.127.21.dist-info}/WHEEL +0 -0
- {cognite_neat-0.127.19.dist-info → cognite_neat-0.127.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,6 +5,8 @@ from typing import Any, Literal
|
|
|
5
5
|
|
|
6
6
|
from cognite.neat._data_model._constants import DEFAULT_MAX_LIST_SIZE, DEFAULT_MAX_LIST_SIZE_DIRECT_RELATIONS
|
|
7
7
|
from cognite.neat._data_model.importers._table_importer.data_classes import (
|
|
8
|
+
CREATOR_KEY,
|
|
9
|
+
CREATOR_MARKER,
|
|
8
10
|
DMSContainer,
|
|
9
11
|
DMSEnum,
|
|
10
12
|
DMSNode,
|
|
@@ -78,14 +80,34 @@ class DMSTableWriter:
|
|
|
78
80
|
)
|
|
79
81
|
|
|
80
82
|
### Metadata Sheet ###
|
|
81
|
-
@
|
|
82
|
-
def write_metadata(data_model: DataModelRequest) -> list[MetadataValue]:
|
|
83
|
-
|
|
83
|
+
@classmethod
|
|
84
|
+
def write_metadata(cls, data_model: DataModelRequest) -> list[MetadataValue]:
|
|
85
|
+
metadata = [
|
|
84
86
|
MetadataValue(key=key, value=value)
|
|
85
87
|
for key, value in data_model.model_dump(
|
|
86
|
-
mode="json", by_alias=True, exclude_none=True, exclude={"views"}
|
|
88
|
+
mode="json", by_alias=True, exclude_none=True, exclude={"views", "description"}
|
|
87
89
|
).items()
|
|
88
90
|
]
|
|
91
|
+
if data_model.description:
|
|
92
|
+
description, creator = cls._serialize_description(data_model.description)
|
|
93
|
+
if description:
|
|
94
|
+
metadata.append(MetadataValue(key="description", value=description))
|
|
95
|
+
if creator:
|
|
96
|
+
metadata.append(MetadataValue(key=CREATOR_KEY, value=creator))
|
|
97
|
+
return metadata
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _serialize_description(description: str | None) -> tuple[str | None, str | None]:
|
|
101
|
+
"""DataModelRequest does not have a 'creator' field, this is a special addition that the Neat tables
|
|
102
|
+
format supports (and recommends using). If the data model was created using Neat, the suffix of the
|
|
103
|
+
description will be Creator: <creator>. This function extracts that information."""
|
|
104
|
+
if description is None:
|
|
105
|
+
return None, None
|
|
106
|
+
if CREATOR_MARKER not in description:
|
|
107
|
+
return description, None
|
|
108
|
+
|
|
109
|
+
description, creator = description.rsplit(CREATOR_MARKER, 1)
|
|
110
|
+
return description.rstrip(), creator.strip()
|
|
89
111
|
|
|
90
112
|
### Container Properties Sheet ###
|
|
91
113
|
|
|
@@ -24,6 +24,10 @@ from cognite.neat.v0.core._data_model.models.entities import (
|
|
|
24
24
|
RawFilter,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
+
# This marker is used to identify creator in the description field.
|
|
28
|
+
CREATOR_MARKER = "Creator: "
|
|
29
|
+
CREATOR_KEY = "creator"
|
|
30
|
+
|
|
27
31
|
|
|
28
32
|
def parse_entity_str(v: str) -> ParsedEntity:
|
|
29
33
|
if isinstance(v, ParsedEntity):
|
|
@@ -9,6 +9,7 @@ from cognite.neat._data_model.models.dms import (
|
|
|
9
9
|
Constraint,
|
|
10
10
|
ConstraintAdapter,
|
|
11
11
|
ContainerPropertyDefinition,
|
|
12
|
+
ContainerReference,
|
|
12
13
|
ContainerRequest,
|
|
13
14
|
DataModelRequest,
|
|
14
15
|
Index,
|
|
@@ -17,17 +18,19 @@ from cognite.neat._data_model.models.dms import (
|
|
|
17
18
|
RequestSchema,
|
|
18
19
|
SpaceRequest,
|
|
19
20
|
UniquenessConstraintDefinition,
|
|
21
|
+
ViewReference,
|
|
20
22
|
ViewRequest,
|
|
21
23
|
ViewRequestProperty,
|
|
22
24
|
ViewRequestPropertyAdapter,
|
|
23
25
|
)
|
|
26
|
+
from cognite.neat._data_model.models.dms._constants import DATA_MODEL_DESCRIPTION_MAX_LENGTH
|
|
24
27
|
from cognite.neat._data_model.models.entities import ParsedEntity, parse_entity
|
|
25
28
|
from cognite.neat._exceptions import DataModelImportException
|
|
26
29
|
from cognite.neat._issues import ModelSyntaxError
|
|
27
30
|
from cognite.neat._utils.text import humanize_collection
|
|
28
31
|
from cognite.neat._utils.validation import ValidationContext, humanize_validation_error
|
|
29
32
|
|
|
30
|
-
from .data_classes import DMSContainer, DMSEnum, DMSNode, DMSProperty, DMSView, TableDMS
|
|
33
|
+
from .data_classes import CREATOR_KEY, CREATOR_MARKER, DMSContainer, DMSEnum, DMSNode, DMSProperty, DMSView, TableDMS
|
|
31
34
|
from .source import TableSource
|
|
32
35
|
|
|
33
36
|
T_BaseModel = TypeVar("T_BaseModel", bound=BaseModel)
|
|
@@ -169,7 +172,9 @@ class DMSTableReader:
|
|
|
169
172
|
space_request = self.read_space(self.default_space)
|
|
170
173
|
node_types = self.read_nodes(tables.nodes)
|
|
171
174
|
enum_collections = self.read_enum_collections(tables.enum)
|
|
172
|
-
|
|
175
|
+
container_ref_by_entity = self.read_entity_by_container_ref(tables.containers)
|
|
176
|
+
view_ref_by_entity = self.read_entity_by_view_ref(tables.views)
|
|
177
|
+
read = self.read_properties(tables.properties, enum_collections, container_ref_by_entity, view_ref_by_entity)
|
|
173
178
|
processed = self.process_properties(read)
|
|
174
179
|
containers = self.read_containers(tables.containers, processed)
|
|
175
180
|
views, valid_view_entities = self.read_views(tables.views, processed.view)
|
|
@@ -207,18 +212,50 @@ class DMSTableReader:
|
|
|
207
212
|
}
|
|
208
213
|
return enum_collections
|
|
209
214
|
|
|
215
|
+
def read_entity_by_container_ref(self, containers: list[DMSContainer]) -> dict[ContainerReference, ParsedEntity]:
|
|
216
|
+
entity_by_container_ref: dict[ContainerReference, ParsedEntity] = {}
|
|
217
|
+
for container in containers:
|
|
218
|
+
data = self._create_container_ref(container.container)
|
|
219
|
+
try:
|
|
220
|
+
container_ref = ContainerReference.model_validate(data)
|
|
221
|
+
except ValidationError:
|
|
222
|
+
# Error will be reported when reading the containers
|
|
223
|
+
continue
|
|
224
|
+
entity_by_container_ref[container_ref] = container.container
|
|
225
|
+
return entity_by_container_ref
|
|
226
|
+
|
|
227
|
+
def read_entity_by_view_ref(self, views: list[DMSView]) -> dict[ViewReference, ParsedEntity]:
|
|
228
|
+
entity_by_view_ref: dict[ViewReference, ParsedEntity] = {}
|
|
229
|
+
for view in views:
|
|
230
|
+
data = self._create_view_ref(view.view)
|
|
231
|
+
try:
|
|
232
|
+
view_ref = ViewReference.model_validate(data)
|
|
233
|
+
except ValidationError:
|
|
234
|
+
# Error will be reported when reading the views
|
|
235
|
+
continue
|
|
236
|
+
entity_by_view_ref[view_ref] = view.view
|
|
237
|
+
return entity_by_view_ref
|
|
238
|
+
|
|
210
239
|
def read_properties(
|
|
211
|
-
self,
|
|
240
|
+
self,
|
|
241
|
+
properties: list[DMSProperty],
|
|
242
|
+
enum_collections: dict[str, dict[str, Any]],
|
|
243
|
+
container_ref_by_entity: dict[ContainerReference, ParsedEntity],
|
|
244
|
+
view_ref_by_entity: dict[ViewReference, ParsedEntity],
|
|
212
245
|
) -> ReadProperties:
|
|
213
246
|
read = ReadProperties()
|
|
247
|
+
view_entities = set(view_ref_by_entity.values())
|
|
248
|
+
container_entities = set(container_ref_by_entity.values())
|
|
214
249
|
for row_no, prop in enumerate(properties):
|
|
215
|
-
self._process_view_property(prop, read, row_no)
|
|
250
|
+
self._process_view_property(prop, read, row_no, view_ref_by_entity, view_entities)
|
|
216
251
|
if prop.container is None or prop.container_property is None:
|
|
217
252
|
# This is when the property is an edge or reverse direct relation property.
|
|
218
253
|
continue
|
|
219
|
-
self._process_container_property(
|
|
220
|
-
|
|
221
|
-
|
|
254
|
+
self._process_container_property(
|
|
255
|
+
prop, read, enum_collections, row_no, container_ref_by_entity, container_entities
|
|
256
|
+
)
|
|
257
|
+
self._process_index(prop, read, row_no, container_ref_by_entity, container_entities)
|
|
258
|
+
self._process_constraint(prop, read, row_no, container_ref_by_entity, container_entities)
|
|
222
259
|
return read
|
|
223
260
|
|
|
224
261
|
def process_properties(self, read: ReadProperties) -> ProcessedProperties:
|
|
@@ -370,31 +407,91 @@ class DMSTableReader:
|
|
|
370
407
|
constraints[container_entity][constraint_id] = constraint
|
|
371
408
|
return constraints
|
|
372
409
|
|
|
373
|
-
def _process_view_property(
|
|
410
|
+
def _process_view_property(
|
|
411
|
+
self,
|
|
412
|
+
prop: DMSProperty,
|
|
413
|
+
read: ReadProperties,
|
|
414
|
+
row_no: int,
|
|
415
|
+
view_ref_by_entity: dict[ViewReference, ParsedEntity],
|
|
416
|
+
view_entities: set[ParsedEntity],
|
|
417
|
+
) -> None:
|
|
374
418
|
loc = (self.Sheets.properties, row_no)
|
|
375
419
|
data = self.read_view_property(prop, loc)
|
|
376
420
|
view_prop = self._validate_adapter(ViewRequestPropertyAdapter, data, loc)
|
|
377
|
-
if view_prop is
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
421
|
+
if view_prop is None:
|
|
422
|
+
return None
|
|
423
|
+
if prop.view in view_entities:
|
|
424
|
+
read.view[(prop.view, prop.view_property)].append(ReadViewProperty(prop.view_property, row_no, view_prop))
|
|
425
|
+
return None
|
|
426
|
+
# The view entity was not found in the views table. This could either be because the view is missing,
|
|
427
|
+
# or because either the view entity in the Properties table and the View table are specified with/without
|
|
428
|
+
# default space/version inconsistently.
|
|
429
|
+
try:
|
|
430
|
+
view_ref = ViewReference.model_validate(self._create_view_ref(prop.view))
|
|
431
|
+
except ValidationError:
|
|
432
|
+
# Error will be reported when reading the views
|
|
433
|
+
return None
|
|
434
|
+
if view_ref in view_ref_by_entity:
|
|
435
|
+
view_ref_entity = view_ref_by_entity[view_ref]
|
|
436
|
+
read.view[(view_ref_entity, prop.view_property)].append(
|
|
437
|
+
ReadViewProperty(prop.view_property, row_no, view_prop)
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
self.errors.append(
|
|
441
|
+
ModelSyntaxError(
|
|
442
|
+
message=(
|
|
443
|
+
f"In {self.source.location(loc)} the View '{prop.view!s}' "
|
|
444
|
+
f"was not found in the {self.Sheets.views!r} table."
|
|
445
|
+
)
|
|
446
|
+
)
|
|
382
447
|
)
|
|
383
448
|
return None
|
|
384
449
|
|
|
385
450
|
def _process_container_property(
|
|
386
|
-
self,
|
|
451
|
+
self,
|
|
452
|
+
prop: DMSProperty,
|
|
453
|
+
read: ReadProperties,
|
|
454
|
+
enum_collections: dict[str, dict[str, Any]],
|
|
455
|
+
row_no: int,
|
|
456
|
+
container_ref_by_entity: dict[ContainerReference, ParsedEntity],
|
|
457
|
+
container_entities: set[ParsedEntity],
|
|
387
458
|
) -> None:
|
|
388
459
|
loc = (self.Sheets.properties, row_no)
|
|
389
460
|
data = self.read_container_property(prop, enum_collections, loc=loc)
|
|
390
461
|
container_prop = self._validate_obj(ContainerPropertyDefinition, data, loc)
|
|
391
|
-
if container_prop is
|
|
462
|
+
if container_prop is None:
|
|
463
|
+
return None
|
|
464
|
+
if not (prop.container and prop.container_property):
|
|
465
|
+
return None
|
|
466
|
+
if prop.container in container_entities:
|
|
392
467
|
read.container[(prop.container, prop.container_property)].append(
|
|
393
468
|
ReadContainerProperty(prop.container_property, row_no, container_prop)
|
|
394
469
|
)
|
|
470
|
+
return None
|
|
471
|
+
# The container entity was not found in the containers table. This could either be because the container
|
|
472
|
+
# is missing, or because either the container entity in the Properties table and the Container table are
|
|
473
|
+
# specified with/without default space/version inconsistently.
|
|
474
|
+
try:
|
|
475
|
+
container_ref = ContainerReference.model_validate(self._create_container_ref(prop.container))
|
|
476
|
+
except ValidationError:
|
|
477
|
+
# Error will be reported when reading the containers
|
|
478
|
+
return None
|
|
479
|
+
if container_ref in container_ref_by_entity:
|
|
480
|
+
container_ref_entity = container_ref_by_entity[container_ref]
|
|
481
|
+
read.container[(container_ref_entity, prop.container_property)].append(
|
|
482
|
+
ReadContainerProperty(prop.container_property, row_no, container_prop)
|
|
483
|
+
)
|
|
484
|
+
# Container can be in CDF, this will be reported by a validator later.
|
|
395
485
|
return None
|
|
396
486
|
|
|
397
|
-
def _process_index(
|
|
487
|
+
def _process_index(
|
|
488
|
+
self,
|
|
489
|
+
prop: DMSProperty,
|
|
490
|
+
read: ReadProperties,
|
|
491
|
+
row_no: int,
|
|
492
|
+
container_ref_by_entity: dict[ContainerReference, ParsedEntity],
|
|
493
|
+
container_entities: set[ParsedEntity],
|
|
494
|
+
) -> None:
|
|
398
495
|
if prop.index is None or prop.container_property is None or prop.container is None:
|
|
399
496
|
return
|
|
400
497
|
|
|
@@ -405,11 +502,41 @@ class DMSTableReader:
|
|
|
405
502
|
if created is None:
|
|
406
503
|
continue
|
|
407
504
|
order = self._read_order(index.properties, loc)
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
505
|
+
|
|
506
|
+
if prop.container in container_entities:
|
|
507
|
+
read.indices[(prop.container, index.suffix)].append(
|
|
508
|
+
ReadIndex(
|
|
509
|
+
prop_id=prop.container_property,
|
|
510
|
+
order=order,
|
|
511
|
+
row_no=row_no,
|
|
512
|
+
index_id=index.suffix,
|
|
513
|
+
index=created,
|
|
514
|
+
)
|
|
411
515
|
)
|
|
412
|
-
|
|
516
|
+
continue
|
|
517
|
+
# The container entity was not found in the containers table. This could either be because the
|
|
518
|
+
# container is missing, or because either the container entity in the Properties table and the
|
|
519
|
+
# Container table are specified with/without default space/version inconsistently.
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
container_ref = ContainerReference.model_validate(self._create_container_ref(prop.container))
|
|
523
|
+
except ValidationError:
|
|
524
|
+
# Error will be reported when reading the containers
|
|
525
|
+
continue
|
|
526
|
+
if container_ref in container_ref_by_entity:
|
|
527
|
+
container_ref_entity = container_ref_by_entity[container_ref]
|
|
528
|
+
read.indices[(container_ref_entity, index.suffix)].append(
|
|
529
|
+
ReadIndex(
|
|
530
|
+
prop_id=prop.container_property,
|
|
531
|
+
order=order,
|
|
532
|
+
row_no=row_no,
|
|
533
|
+
index_id=index.suffix,
|
|
534
|
+
index=created,
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
# Error is reported when reading the property.
|
|
539
|
+
...
|
|
413
540
|
|
|
414
541
|
def _read_order(self, properties: dict[str, Any], loc: tuple[str | int, ...]) -> int | None:
|
|
415
542
|
if "order" not in properties:
|
|
@@ -433,7 +560,14 @@ class DMSTableReader:
|
|
|
433
560
|
**index.properties,
|
|
434
561
|
}
|
|
435
562
|
|
|
436
|
-
def _process_constraint(
|
|
563
|
+
def _process_constraint(
|
|
564
|
+
self,
|
|
565
|
+
prop: DMSProperty,
|
|
566
|
+
read: ReadProperties,
|
|
567
|
+
row_no: int,
|
|
568
|
+
container_ref_by_entity: dict[ContainerReference, ParsedEntity],
|
|
569
|
+
container_entities: set[ParsedEntity],
|
|
570
|
+
) -> None:
|
|
437
571
|
if prop.constraint is None or prop.container_property is None or prop.container is None:
|
|
438
572
|
return
|
|
439
573
|
loc = (self.Sheets.properties, row_no, self.PropertyColumns.constraint)
|
|
@@ -443,15 +577,40 @@ class DMSTableReader:
|
|
|
443
577
|
if created is None:
|
|
444
578
|
continue
|
|
445
579
|
order = self._read_order(constraint.properties, loc)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
580
|
+
|
|
581
|
+
if prop.container in container_entities:
|
|
582
|
+
read.constraints[(prop.container, constraint.suffix)].append(
|
|
583
|
+
ReadConstraint(
|
|
584
|
+
prop_id=prop.container_property,
|
|
585
|
+
order=order,
|
|
586
|
+
constraint_id=constraint.suffix,
|
|
587
|
+
row_no=row_no,
|
|
588
|
+
constraint=created,
|
|
589
|
+
)
|
|
453
590
|
)
|
|
454
|
-
|
|
591
|
+
continue
|
|
592
|
+
# The container entity was not found in the containers table. This could either be because the
|
|
593
|
+
# container is missing, or because either the container entity in the Properties table and the
|
|
594
|
+
# Container table are specified with/without default space/version inconsistently.
|
|
595
|
+
try:
|
|
596
|
+
container_ref = ContainerReference.model_validate(self._create_container_ref(prop.container))
|
|
597
|
+
except ValidationError:
|
|
598
|
+
# Error will be reported when reading the containers
|
|
599
|
+
continue
|
|
600
|
+
if container_ref in container_ref_by_entity:
|
|
601
|
+
container_ref_entity = container_ref_by_entity[container_ref]
|
|
602
|
+
read.constraints[(container_ref_entity, constraint.suffix)].append(
|
|
603
|
+
ReadConstraint(
|
|
604
|
+
prop_id=prop.container_property,
|
|
605
|
+
order=order,
|
|
606
|
+
constraint_id=constraint.suffix,
|
|
607
|
+
row_no=row_no,
|
|
608
|
+
constraint=created,
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
# Error is reported when reading the property.
|
|
613
|
+
...
|
|
455
614
|
|
|
456
615
|
@staticmethod
|
|
457
616
|
def read_property_constraint(constraint: ParsedEntity, prop_id: str) -> dict[str, Any]:
|
|
@@ -733,16 +892,45 @@ class DMSTableReader:
|
|
|
733
892
|
return views_requests, set(rows_by_seen.keys())
|
|
734
893
|
|
|
735
894
|
def read_data_model(self, tables: TableDMS, valid_view_entities: set[ParsedEntity]) -> DataModelRequest:
|
|
736
|
-
data = {
|
|
895
|
+
data: dict[str, Any] = {
|
|
737
896
|
**{meta.key: meta.value for meta in tables.metadata},
|
|
738
897
|
"views": [self._create_view_ref(view.view) for view in tables.views if view.view in valid_view_entities],
|
|
739
898
|
}
|
|
899
|
+
if description := self._create_description_field(data):
|
|
900
|
+
data["description"] = description
|
|
740
901
|
model = self._validate_obj(DataModelRequest, data, (self.Sheets.metadata,), field_name="value")
|
|
741
902
|
if model is None:
|
|
742
903
|
# This is the last step, so we can raise the error here.
|
|
743
904
|
raise DataModelImportException(self.errors) from None
|
|
744
905
|
return model
|
|
745
906
|
|
|
907
|
+
def _create_description_field(self, data: dict[str, Any]) -> str | None:
|
|
908
|
+
"""DataModelRequest does not have a 'creator' field, this is a special addition that the Neat tables
|
|
909
|
+
format supports (and recommends using). To keep it, Neat adds it to the suffix of the description field.
|
|
910
|
+
"""
|
|
911
|
+
if CREATOR_KEY not in data and CREATOR_KEY.title() not in data:
|
|
912
|
+
return None
|
|
913
|
+
creator_val = data.pop(CREATOR_KEY, data.pop(CREATOR_KEY.title(), None))
|
|
914
|
+
|
|
915
|
+
if not creator_val:
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
creator = str(creator_val)
|
|
919
|
+
# We do a split/join to clean up any spaces around commas. Ensuring that we have a consistent
|
|
920
|
+
# canonical format.
|
|
921
|
+
cleaned_creator = ", ".join(item.strip() for item in creator.split(","))
|
|
922
|
+
if not cleaned_creator:
|
|
923
|
+
return None
|
|
924
|
+
suffix = f"{CREATOR_MARKER}{cleaned_creator}"
|
|
925
|
+
description = data.get("description", "")
|
|
926
|
+
if len(description) + len(suffix) > DATA_MODEL_DESCRIPTION_MAX_LENGTH:
|
|
927
|
+
description = description[: DATA_MODEL_DESCRIPTION_MAX_LENGTH - len(suffix) - 4] + "..."
|
|
928
|
+
if description:
|
|
929
|
+
description = f"{description} {suffix}"
|
|
930
|
+
else:
|
|
931
|
+
description = suffix
|
|
932
|
+
return description
|
|
933
|
+
|
|
746
934
|
def _parse_entity(self, entity: str, loc: tuple[str | int, ...]) -> ParsedEntity | None:
|
|
747
935
|
try:
|
|
748
936
|
parsed = parse_entity(entity)
|
|
@@ -4,6 +4,7 @@ CONTAINER_AND_VIEW_PROPERTIES_IDENTIFIER_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0
|
|
|
4
4
|
INSTANCE_ID_PATTERN = r"^[^\x00]{1,256}$"
|
|
5
5
|
ENUM_VALUE_IDENTIFIER_PATTERN = r"^[_A-Za-z][_0-9A-Za-z]{0,127}$"
|
|
6
6
|
DM_VERSION_PATTERN = r"^[a-zA-Z0-9]([.a-zA-Z0-9_-]{0,41}[a-zA-Z0-9])?$"
|
|
7
|
+
DATA_MODEL_DESCRIPTION_MAX_LENGTH = 1024
|
|
7
8
|
FORBIDDEN_ENUM_VALUES = frozenset({"true", "false", "null"})
|
|
8
9
|
FORBIDDEN_SPACES = frozenset(["space", "cdf", "dms", "pg3", "shared", "system", "node", "edge"])
|
|
9
10
|
FORBIDDEN_CONTAINER_AND_VIEW_EXTERNAL_IDS = frozenset(
|
|
@@ -6,6 +6,7 @@ from pydantic_core.core_schema import FieldSerializationInfo
|
|
|
6
6
|
|
|
7
7
|
from ._base import APIResource, Resource, WriteableResource
|
|
8
8
|
from ._constants import (
|
|
9
|
+
DATA_MODEL_DESCRIPTION_MAX_LENGTH,
|
|
9
10
|
DM_EXTERNAL_ID_PATTERN,
|
|
10
11
|
DM_VERSION_PATTERN,
|
|
11
12
|
SPACE_FORMAT_PATTERN,
|
|
@@ -46,7 +47,7 @@ class DataModel(Resource, APIResource[DataModelReference], ABC):
|
|
|
46
47
|
description: str | None = Field(
|
|
47
48
|
default=None,
|
|
48
49
|
description="Description of the data model.",
|
|
49
|
-
max_length=
|
|
50
|
+
max_length=DATA_MODEL_DESCRIPTION_MAX_LENGTH,
|
|
50
51
|
)
|
|
51
52
|
# The API supports View here, but in Neat we will only use ViewReference
|
|
52
53
|
views: list[ViewReference] | None = Field(
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
let currentFilter = 'all';
|
|
2
2
|
let currentSearch = '';
|
|
3
|
-
|
|
3
|
+
const storageKey = 'neat-issues-theme-' + uniqueId;
|
|
4
|
+
let isDarkMode = localStorage.getItem(storageKey) === 'dark';
|
|
4
5
|
let expandedGroups = new Set();
|
|
5
6
|
|
|
6
|
-
const container = document.getElementById('issuesContainer');
|
|
7
|
-
const themeToggle = document.getElementById('themeToggle');
|
|
8
|
-
const themeIcon = document.getElementById('themeIcon');
|
|
9
|
-
const themeText = document.getElementById('themeText');
|
|
7
|
+
const container = document.getElementById('issuesContainer-' + uniqueId);
|
|
8
|
+
const themeToggle = document.getElementById('themeToggle-' + uniqueId);
|
|
9
|
+
const themeIcon = document.getElementById('themeIcon-' + uniqueId);
|
|
10
|
+
const themeText = document.getElementById('themeText-' + uniqueId);
|
|
10
11
|
|
|
11
12
|
// Initialize theme
|
|
12
13
|
function updateTheme() {
|
|
@@ -26,7 +27,7 @@ updateTheme();
|
|
|
26
27
|
// Theme toggle
|
|
27
28
|
themeToggle.addEventListener('click', function() {
|
|
28
29
|
isDarkMode = !isDarkMode;
|
|
29
|
-
localStorage.setItem(
|
|
30
|
+
localStorage.setItem(storageKey, isDarkMode ? 'dark' : 'light');
|
|
30
31
|
updateTheme();
|
|
31
32
|
});
|
|
32
33
|
|
|
@@ -46,7 +47,7 @@ function groupIssues(issuesList) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
function renderIssues() {
|
|
49
|
-
const listContainer = document.getElementById('issuesList');
|
|
50
|
+
const listContainer = document.getElementById('issuesList-' + uniqueId);
|
|
50
51
|
const filtered = issues.filter(issue => {
|
|
51
52
|
const matchesFilter = currentFilter === 'all' || issue.type === currentFilter;
|
|
52
53
|
const matchesSearch = !currentSearch ||
|
|
@@ -93,7 +94,7 @@ function renderIssues() {
|
|
|
93
94
|
// Grouped issues
|
|
94
95
|
html.push(`
|
|
95
96
|
<div class="issue-group ${isExpanded ? 'expanded' : ''}">
|
|
96
|
-
<div class="issue-group-header" onclick="
|
|
97
|
+
<div class="issue-group-header" onclick="toggleGroup_${uniqueId}('${key}')">
|
|
97
98
|
<div class="issue-group-info">
|
|
98
99
|
<span class="expand-icon">${isExpanded ? '▼' : '▶'}</span>
|
|
99
100
|
<span class="issue-badge badge-${firstIssue.type}">${firstIssue.type}</span>
|
|
@@ -123,7 +124,7 @@ function renderIssues() {
|
|
|
123
124
|
listContainer.innerHTML = html.join('');
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
window
|
|
127
|
+
window['toggleGroup_' + uniqueId] = function(key) {
|
|
127
128
|
if (expandedGroups.has(key)) {
|
|
128
129
|
expandedGroups.delete(key);
|
|
129
130
|
} else {
|
|
@@ -133,9 +134,9 @@ window.toggleGroup = function(key) {
|
|
|
133
134
|
};
|
|
134
135
|
|
|
135
136
|
// Stat item filters
|
|
136
|
-
document.querySelectorAll('.stat-item').forEach(item => {
|
|
137
|
+
document.querySelectorAll('#issuesContainer-' + uniqueId + ' .stat-item').forEach(item => {
|
|
137
138
|
item.addEventListener('click', function() {
|
|
138
|
-
document.querySelectorAll('.stat-item').forEach(i => i.classList.remove('active'));
|
|
139
|
+
document.querySelectorAll('#issuesContainer-' + uniqueId + ' .stat-item').forEach(i => i.classList.remove('active'));
|
|
139
140
|
this.classList.add('active');
|
|
140
141
|
currentFilter = this.dataset.filter;
|
|
141
142
|
renderIssues();
|
|
@@ -143,13 +144,13 @@ document.querySelectorAll('.stat-item').forEach(item => {
|
|
|
143
144
|
});
|
|
144
145
|
|
|
145
146
|
// Search
|
|
146
|
-
document.getElementById('searchInput').addEventListener('input', function(e) {
|
|
147
|
+
document.getElementById('searchInput-' + uniqueId).addEventListener('input', function(e) {
|
|
147
148
|
currentSearch = e.target.value;
|
|
148
149
|
renderIssues();
|
|
149
150
|
});
|
|
150
151
|
|
|
151
152
|
// Export function
|
|
152
|
-
window
|
|
153
|
+
window['exportIssues_' + uniqueId] = function() {
|
|
153
154
|
const csv = [
|
|
154
155
|
['Type', 'Code', 'Message', 'Fix'],
|
|
155
156
|
...issues.map(i => [i.type, i.code || '', i.message, i.fix || ''])
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
{{SPECIFIC_CSS}}
|
|
4
4
|
</style>
|
|
5
5
|
|
|
6
|
-
<div class="issues-container" id="issuesContainer">
|
|
6
|
+
<div class="issues-container" id="issuesContainer-{{unique_id}}">
|
|
7
7
|
<div class="issues-header">
|
|
8
|
-
<button class="theme-toggle" id="themeToggle">
|
|
9
|
-
<span id="themeIcon">🌙</span>
|
|
10
|
-
<span id="themeText">Dark</span>
|
|
8
|
+
<button class="theme-toggle" id="themeToggle-{{unique_id}}">
|
|
9
|
+
<span id="themeIcon-{{unique_id}}">🌙</span>
|
|
10
|
+
<span id="themeText-{{unique_id}}">Dark</span>
|
|
11
11
|
</button>
|
|
12
12
|
<h2 class="issues-title">Session Issues</h2>
|
|
13
13
|
<div class="issues-stats">
|
|
@@ -23,22 +23,23 @@
|
|
|
23
23
|
<div class="stat-item" data-filter="Recommendation">
|
|
24
24
|
<span class="stat-number">{{recommendations}}</span> Recommendations
|
|
25
25
|
</div>
|
|
26
|
-
<button class="export-btn" onclick="
|
|
26
|
+
<button class="export-btn" onclick="exportIssues_{{unique_id}}()">Export CSV</button>
|
|
27
27
|
</div>
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
30
|
<div class="issues-controls">
|
|
31
31
|
<div class="control-group">
|
|
32
|
-
<input type="text" class="search-input" placeholder="🔍 Search messages, codes, fixes..." id="searchInput">
|
|
32
|
+
<input type="text" class="search-input" placeholder="🔍 Search messages, codes, fixes..." id="searchInput-{{unique_id}}">
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
35
|
|
|
36
|
-
<div class="issues-list" id="issuesList"></div>
|
|
36
|
+
<div class="issues-list" id="issuesList-{{unique_id}}"></div>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
39
|
<script>
|
|
40
40
|
(function() {
|
|
41
41
|
const issues = {{JSON}};
|
|
42
|
+
const uniqueId = '{{unique_id}}';
|
|
42
43
|
{{SCRIPTS}}
|
|
43
44
|
})();
|
|
44
45
|
</script>
|
cognite/neat/_session/_issues.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import uuid
|
|
2
3
|
from collections import defaultdict
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
@@ -65,12 +66,16 @@ class Issues:
|
|
|
65
66
|
return "<b>No issues found.</b>"
|
|
66
67
|
stats = self._stats
|
|
67
68
|
|
|
69
|
+
# Generate unique ID for this render to avoid conflicts in Jupyter
|
|
70
|
+
unique_id = uuid.uuid4().hex[:8]
|
|
71
|
+
|
|
68
72
|
template_vars = {
|
|
69
73
|
"JSON": json.dumps(self._serialized_issues),
|
|
70
74
|
"total": stats["total"],
|
|
71
75
|
"syntax_errors": stats["by_type"].get("ModelSyntaxError", 0),
|
|
72
76
|
"consistency_errors": stats["by_type"].get("ConsistencyError", 0),
|
|
73
77
|
"recommendations": stats["by_type"].get("Recommendation", 0),
|
|
78
|
+
"unique_id": unique_id,
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
return render("issues", template_vars)
|
cognite/neat/_version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "0.127.
|
|
1
|
+
__version__ = "0.127.21"
|
|
2
2
|
__engine__ = "^2.0.4"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cognite-neat
|
|
3
|
-
Version: 0.127.
|
|
3
|
+
Version: 0.127.21
|
|
4
4
|
Summary: Knowledge graph transformation
|
|
5
5
|
Project-URL: Documentation, https://cognite-neat.readthedocs-hosted.com/
|
|
6
6
|
Project-URL: Homepage, https://cognite-neat.readthedocs-hosted.com/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
cognite/neat/__init__.py,sha256=Lo4DbjDOwnhCYUoAgPp5RG1fDdF7OlnomalTe7n1ydw,211
|
|
2
2
|
cognite/neat/_exceptions.py,sha256=ox-5hXpee4UJlPE7HpuEHV2C96aLbLKo-BhPDoOAzhA,1650
|
|
3
3
|
cognite/neat/_issues.py,sha256=wH1mnkrpBsHUkQMGUHFLUIQWQlfJ_qMfdF7q0d9wNhY,1871
|
|
4
|
-
cognite/neat/_version.py,sha256=
|
|
4
|
+
cognite/neat/_version.py,sha256=6TSmw-RWqsWG5I8-dNbvOb-gEQhv4rs3V4jd6qB5P8s,47
|
|
5
5
|
cognite/neat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
cognite/neat/v1.py,sha256=owqW5Mml2DSZx1AvPvwNRTBngfhBNrQ6EH-7CKL7Jp0,61
|
|
7
7
|
cognite/neat/_client/__init__.py,sha256=75Bh7eGhaN4sOt3ZcRzHl7pXaheu1z27kmTHeaI05vo,114
|
|
@@ -33,14 +33,14 @@ cognite/neat/_data_model/exporters/_base.py,sha256=rG_qAU5i5Hh5hUMep2UmDFFZID4x3
|
|
|
33
33
|
cognite/neat/_data_model/exporters/_table_exporter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
34
|
cognite/neat/_data_model/exporters/_table_exporter/exporter.py,sha256=4BPu_Chtjh1EyOaKbThXYohsqllVOkCbSoNekNZuBXc,5159
|
|
35
35
|
cognite/neat/_data_model/exporters/_table_exporter/workbook.py,sha256=1Afk1WqeNe9tiNeSAm0HrF8jTQ1kTbIv1D9hMztKwO8,18482
|
|
36
|
-
cognite/neat/_data_model/exporters/_table_exporter/writer.py,sha256=
|
|
36
|
+
cognite/neat/_data_model/exporters/_table_exporter/writer.py,sha256=QsO2BWB-_Jw_hpawHtG26NOnLu6wwtDosT-c1acNLPw,19270
|
|
37
37
|
cognite/neat/_data_model/importers/__init__.py,sha256=dHnKnC_AXk42z6wzEHK15dxIOh8xSEkuUf_AFRZls0E,193
|
|
38
38
|
cognite/neat/_data_model/importers/_api_importer.py,sha256=H8Ow3Tt7utuAuBhC6s7yWvhGqunHAtE0r0XRsVAr6IE,7280
|
|
39
39
|
cognite/neat/_data_model/importers/_base.py,sha256=NRB0FcEBj4GaethU68nRffBfTedBBA866A3zfJNfmiQ,433
|
|
40
40
|
cognite/neat/_data_model/importers/_table_importer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
41
|
-
cognite/neat/_data_model/importers/_table_importer/data_classes.py,sha256=
|
|
41
|
+
cognite/neat/_data_model/importers/_table_importer/data_classes.py,sha256=7oy0dYXj2lW2F-9jzrosSAIlDBzcAewMu8e5sU5lHPw,9618
|
|
42
42
|
cognite/neat/_data_model/importers/_table_importer/importer.py,sha256=lQ4_Gpv0haEwQEDYZJaxtR9dL6Y0ys9jbjFfWxH6s2o,8870
|
|
43
|
-
cognite/neat/_data_model/importers/_table_importer/reader.py,sha256=
|
|
43
|
+
cognite/neat/_data_model/importers/_table_importer/reader.py,sha256=I9-zHCpJLo7bj4BabAzSgNBDVUAocdhlvBfy95JkWRw,49451
|
|
44
44
|
cognite/neat/_data_model/importers/_table_importer/source.py,sha256=h7u5ur5oetmvBs3wgj7Ody5uPF21QwxeAceoIhJ5qzo,3300
|
|
45
45
|
cognite/neat/_data_model/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
46
46
|
cognite/neat/_data_model/models/conceptual/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -51,10 +51,10 @@ cognite/neat/_data_model/models/conceptual/_properties.py,sha256=CpF37vJYBTLT4DH
|
|
|
51
51
|
cognite/neat/_data_model/models/conceptual/_property.py,sha256=blSZQxX52zaILAtjUkldPzPeysz7wnG-UGSNU5tacI8,4138
|
|
52
52
|
cognite/neat/_data_model/models/dms/__init__.py,sha256=CW5NPMRrMyY4iyZgqYb8eZkRuwbbXUDSVNMWep3zEPI,5326
|
|
53
53
|
cognite/neat/_data_model/models/dms/_base.py,sha256=931ODXnhrBrzf6vkjqu2IaFz8r2gGxuapn85yN_jkgg,889
|
|
54
|
-
cognite/neat/_data_model/models/dms/_constants.py,sha256=
|
|
54
|
+
cognite/neat/_data_model/models/dms/_constants.py,sha256=TaoE9kmNVEaTl_dDrZQL7YzgP4K13ff0Rc7nr4zbIgg,1384
|
|
55
55
|
cognite/neat/_data_model/models/dms/_constraints.py,sha256=cyGgDlByXAuSMWJg7Oc25fkp33LsA61M927bCzTWlbo,1458
|
|
56
56
|
cognite/neat/_data_model/models/dms/_container.py,sha256=wtQbNUwtpymltT1jav8wD4kIfjaIYnvhhz1KS0ffAbo,6044
|
|
57
|
-
cognite/neat/_data_model/models/dms/_data_model.py,sha256=
|
|
57
|
+
cognite/neat/_data_model/models/dms/_data_model.py,sha256=tq_JGNN-1JxG46bhBhunZiLedklYbDXFEfINB0x3a3Q,3219
|
|
58
58
|
cognite/neat/_data_model/models/dms/_data_types.py,sha256=FMt_d5aJD-o3s9VQWyyCVlHk7D_p3RlSNXBP1OACPs4,6424
|
|
59
59
|
cognite/neat/_data_model/models/dms/_http.py,sha256=YIRRowqkphFAYkx3foTeLyPMe9fNnmzhUCBDXe0u9Kk,926
|
|
60
60
|
cognite/neat/_data_model/models/dms/_indexes.py,sha256=ZtXe8ABuRcsAwRIZ9FCanS3uwZHpkOAhvDvjSXtx_Fs,900
|
|
@@ -83,7 +83,7 @@ cognite/neat/_data_model/validation/dms/_limits.py,sha256=LVxF1qmeEdUVVAPmywCgxW
|
|
|
83
83
|
cognite/neat/_data_model/validation/dms/_orchestrator.py,sha256=FChXB7zflQtnR46BRUFkDCwosLu9YeFLeydEoyFtpVI,10176
|
|
84
84
|
cognite/neat/_data_model/validation/dms/_views.py,sha256=3bHEEbFKTR_QH_tiJYHppQsZ9ruApv-kdyfehEjIlCU,4198
|
|
85
85
|
cognite/neat/_session/__init__.py,sha256=owqW5Mml2DSZx1AvPvwNRTBngfhBNrQ6EH-7CKL7Jp0,61
|
|
86
|
-
cognite/neat/_session/_issues.py,sha256=
|
|
86
|
+
cognite/neat/_session/_issues.py,sha256=E8UQeSJURg2dm4MF1pfD9dp-heSRT7pgQZgKlD1-FGs,2723
|
|
87
87
|
cognite/neat/_session/_opt.py,sha256=QcVK08JMmVzJpD0GKHelbljMOQi6CMD1w-maQOlbyZQ,1350
|
|
88
88
|
cognite/neat/_session/_physical.py,sha256=VyMvvPyN9khR_26rMS0kVuZg23k5c8Hfs7XDeMrkF_w,10526
|
|
89
89
|
cognite/neat/_session/_result.py,sha256=P_7d92OSSJAusjKMTTdsc67CKnPDGUSitlGxSk0KB_A,7714
|
|
@@ -95,11 +95,11 @@ cognite/neat/_session/_html/static/__init__.py,sha256=ZLQFJMITBgbiyTRaVbFAm1l-Dh
|
|
|
95
95
|
cognite/neat/_session/_html/static/deployment.css,sha256=wRv2G0NKIxSq4kyOqd3ajZY60KeT4D6-D3lG-TmzURY,4894
|
|
96
96
|
cognite/neat/_session/_html/static/deployment.js,sha256=3pcQYaW9NAGfeMotZIUIvA6PsWCyrMkJkz3ykNB5lh4,5490
|
|
97
97
|
cognite/neat/_session/_html/static/issues.css,sha256=Egvqo2cnY8FKTtZp_v3rTWcIgb1vTJvToNCJJovWm70,3824
|
|
98
|
-
cognite/neat/_session/_html/static/issues.js,sha256=
|
|
98
|
+
cognite/neat/_session/_html/static/issues.js,sha256=NHx_iAsZTvpZjOoFsxFU_sxqjF4-F4EdHRmoc2DjpIE,6348
|
|
99
99
|
cognite/neat/_session/_html/static/shared.css,sha256=uUm5fqK1zrMBWCuAWdUoBRaAj9AO611hUxuGvxMzbzc,4190
|
|
100
100
|
cognite/neat/_session/_html/templates/__init__.py,sha256=hgufJuBxUZ2nLCMTCxGixmk5ztZF38HzPcvtBkWJwxw,128
|
|
101
101
|
cognite/neat/_session/_html/templates/deployment.html,sha256=COETMP0EEA46xzPlY-RTHo5TdtonV-dscZr15GAY0fs,3309
|
|
102
|
-
cognite/neat/_session/_html/templates/issues.html,sha256=
|
|
102
|
+
cognite/neat/_session/_html/templates/issues.html,sha256=k9Ml3LLeWGhpMfqSoCl6QuNRRy__E6Sa5S_I1Kc33NU,1659
|
|
103
103
|
cognite/neat/_session/_usage_analytics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
104
104
|
cognite/neat/_session/_usage_analytics/_collector.py,sha256=XRyeIW7Att3pIgLpNxQ2joGdIDijfgrPfX7G13OKz-o,4786
|
|
105
105
|
cognite/neat/_session/_usage_analytics/_constants.py,sha256=-tVdYrCTMKfuMlbO7AlzC29Nug41ug6uuX9DFuihpJg,561
|
|
@@ -312,7 +312,7 @@ cognite/neat/v0/session/engine/__init__.py,sha256=D3MxUorEs6-NtgoICqtZ8PISQrjrr4
|
|
|
312
312
|
cognite/neat/v0/session/engine/_import.py,sha256=1QxA2_EK613lXYAHKQbZyw2yjo5P9XuiX4Z6_6-WMNQ,169
|
|
313
313
|
cognite/neat/v0/session/engine/_interface.py,sha256=3W-cYr493c_mW3P5O6MKN1xEQg3cA7NHR_ev3zdF9Vk,533
|
|
314
314
|
cognite/neat/v0/session/engine/_load.py,sha256=u0x7vuQCRoNcPt25KJBJRn8sJabonYK4vtSZpiTdP4k,5201
|
|
315
|
-
cognite_neat-0.127.
|
|
316
|
-
cognite_neat-0.127.
|
|
317
|
-
cognite_neat-0.127.
|
|
318
|
-
cognite_neat-0.127.
|
|
315
|
+
cognite_neat-0.127.21.dist-info/METADATA,sha256=B9N21gWYbuIj-g0hT8DstCeNKA-QQ838OHJ-c1Ws1FM,9150
|
|
316
|
+
cognite_neat-0.127.21.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
317
|
+
cognite_neat-0.127.21.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
|
|
318
|
+
cognite_neat-0.127.21.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|