cognite-neat 0.84.0__py3-none-any.whl → 0.85.0__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.
Files changed (42) hide show
  1. cognite/neat/__init__.py +2 -1
  2. cognite/neat/_shared.py +17 -1
  3. cognite/neat/_version.py +1 -1
  4. cognite/neat/graph/extractors/__init__.py +22 -0
  5. cognite/neat/graph/extractors/_base.py +5 -0
  6. cognite/neat/graph/extractors/_classic_cdf/_assets.py +7 -0
  7. cognite/neat/graph/extractors/_classic_cdf/_events.py +7 -0
  8. cognite/neat/graph/extractors/_classic_cdf/_labels.py +7 -0
  9. cognite/neat/graph/extractors/_classic_cdf/_relationships.py +7 -0
  10. cognite/neat/graph/extractors/_classic_cdf/_sequences.py +7 -0
  11. cognite/neat/graph/extractors/_classic_cdf/_timeseries.py +7 -0
  12. cognite/neat/graph/extractors/_dexpi.py +1 -1
  13. cognite/neat/graph/extractors/_rdf_file.py +8 -0
  14. cognite/neat/graph/loaders/__init__.py +19 -0
  15. cognite/neat/graph/loaders/_base.py +22 -45
  16. cognite/neat/graph/loaders/_rdf2asset.py +123 -0
  17. cognite/neat/graph/loaders/_rdf2dms.py +23 -11
  18. cognite/neat/graph/stores/_base.py +17 -3
  19. cognite/neat/graph/stores/_provenance.py +11 -3
  20. cognite/neat/rules/exporters/__init__.py +23 -1
  21. cognite/neat/rules/exporters/_base.py +5 -0
  22. cognite/neat/rules/exporters/_rules2dms.py +1 -1
  23. cognite/neat/rules/exporters/_rules2ontology.py +6 -0
  24. cognite/neat/rules/importers/__init__.py +20 -0
  25. cognite/neat/rules/importers/_base.py +8 -3
  26. cognite/neat/rules/importers/_dms2rules.py +10 -0
  27. cognite/neat/rules/importers/_dtdl2rules/dtdl_importer.py +1 -1
  28. cognite/neat/rules/importers/_inference2rules.py +5 -1
  29. cognite/neat/rules/importers/_spreadsheet2rules.py +17 -0
  30. cognite/neat/rules/models/_rdfpath.py +41 -2
  31. cognite/neat/rules/models/asset/_rules.py +5 -2
  32. cognite/neat/rules/models/information/_converter.py +27 -0
  33. cognite/neat/rules/models/information/_rules.py +6 -1
  34. cognite/neat/utils/auth.py +298 -0
  35. cognite/neat/utils/auxiliary.py +24 -0
  36. cognite/neat/utils/upload.py +5 -42
  37. {cognite_neat-0.84.0.dist-info → cognite_neat-0.85.0.dist-info}/METADATA +3 -1
  38. {cognite_neat-0.84.0.dist-info → cognite_neat-0.85.0.dist-info}/RECORD +42 -40
  39. /cognite/neat/utils/{xml.py → xml_.py} +0 -0
  40. {cognite_neat-0.84.0.dist-info → cognite_neat-0.85.0.dist-info}/LICENSE +0 -0
  41. {cognite_neat-0.84.0.dist-info → cognite_neat-0.85.0.dist-info}/WHEEL +0 -0
  42. {cognite_neat-0.84.0.dist-info → cognite_neat-0.85.0.dist-info}/entry_points.txt +0 -0
@@ -7,6 +7,7 @@ from cognite.client import CogniteClient
7
7
 
8
8
  from cognite.neat.rules._shared import Rules
9
9
  from cognite.neat.rules.models import DMSRules, InformationRules, RoleTypes
10
+ from cognite.neat.utils.auxiliary import class_html_doc
10
11
  from cognite.neat.utils.upload import UploadResult, UploadResultList
11
12
 
12
13
  T_Export = TypeVar("T_Export")
@@ -34,6 +35,10 @@ class BaseExporter(ABC, Generic[T_Export]):
34
35
  else:
35
36
  raise NotImplementedError(f"Role {output_role} is not supported for {type(rules).__name__} rules")
36
37
 
38
+ @classmethod
39
+ def _repr_html_(cls) -> str:
40
+ return class_html_doc(cls, include_factory_methods=False)
41
+
37
42
 
38
43
  class CDFExporter(BaseExporter[T_Export]):
39
44
  @abstractmethod
@@ -39,7 +39,7 @@ Component: TypeAlias = Literal["all", "spaces", "data_models", "views", "contain
39
39
 
40
40
 
41
41
  class DMSExporter(CDFExporter[DMSSchema]):
42
- """Class for exporting rules object to CDF Data Model Storage (DMS).
42
+ """Export rules to Cognite Data Fusion's Data Model Storage (DMS) service.
43
43
 
44
44
  Args:
45
45
  export_components (frozenset[Literal["all", "spaces", "data_models", "views", "containers"]], optional):
@@ -39,16 +39,22 @@ class GraphExporter(BaseExporter[Graph], ABC):
39
39
 
40
40
 
41
41
  class OWLExporter(GraphExporter):
42
+ """Exports rules to an OWL ontology."""
43
+
42
44
  def export(self, rules: Rules) -> Graph:
43
45
  return Ontology.from_rules(rules).as_owl()
44
46
 
45
47
 
46
48
  class SHACLExporter(GraphExporter):
49
+ """Exports rules to a SHACL graph."""
50
+
47
51
  def export(self, rules: Rules) -> Graph:
48
52
  return Ontology.from_rules(rules).as_shacl()
49
53
 
50
54
 
51
55
  class SemanticDataModelExporter(GraphExporter):
56
+ """Exports rules to a semantic data model."""
57
+
52
58
  def export(self, rules: Rules) -> Graph:
53
59
  return Ontology.from_rules(rules).as_semantic_data_model()
54
60
 
@@ -16,3 +16,23 @@ __all__ = [
16
16
  "YAMLImporter",
17
17
  "InferenceImporter",
18
18
  ]
19
+
20
+
21
+ def _repr_html_() -> str:
22
+ import pandas as pd
23
+
24
+ table = pd.DataFrame( # type: ignore[operator]
25
+ [
26
+ {
27
+ "Importer": name,
28
+ "Description": globals()[name].__doc__.strip().split("\n")[0] if globals()[name].__doc__ else "Missing",
29
+ }
30
+ for name in __all__
31
+ if name != "BaseImporter"
32
+ ]
33
+ )._repr_html_()
34
+
35
+ return (
36
+ "<strong>Importer</strong> An importer reads data/schema/data model from a source"
37
+ f" and converts it into Neat's representation of a data model called <em>Rules</em>.<br />{table}"
38
+ )
@@ -11,7 +11,8 @@ from rdflib import Namespace
11
11
 
12
12
  from cognite.neat.rules._shared import Rules
13
13
  from cognite.neat.rules.issues.base import IssueList, NeatValidationError, ValidationWarning
14
- from cognite.neat.rules.models import DMSRules, InformationRules, RoleTypes
14
+ from cognite.neat.rules.models import AssetRules, DMSRules, InformationRules, RoleTypes
15
+ from cognite.neat.utils.auxiliary import class_html_doc
15
16
 
16
17
 
17
18
  class BaseImporter(ABC):
@@ -48,9 +49,9 @@ class BaseImporter(ABC):
48
49
 
49
50
  if rules.metadata.role is role or role is None:
50
51
  output = rules
51
- elif isinstance(rules, DMSRules) and role is RoleTypes.information_architect:
52
+ elif isinstance(rules, DMSRules) or isinstance(rules, AssetRules) and role is RoleTypes.information_architect:
52
53
  output = rules.as_information_architect_rules()
53
- elif isinstance(rules, InformationRules) and role is RoleTypes.dms_architect:
54
+ elif isinstance(rules, InformationRules) or isinstance(rules, AssetRules) and role is RoleTypes.dms_architect:
54
55
  output = rules.as_dms_architect_rules()
55
56
  else:
56
57
  raise NotImplementedError(f"Role {role} is not supported for {type(rules).__name__} rules")
@@ -79,6 +80,10 @@ class BaseImporter(ABC):
79
80
  "description": f"Imported using {type(self).__name__}",
80
81
  }
81
82
 
83
+ @classmethod
84
+ def _repr_html_(cls) -> str:
85
+ return class_html_doc(cls)
86
+
82
87
 
83
88
  class _FutureResult:
84
89
  def __init__(self) -> None:
@@ -46,6 +46,16 @@ from cognite.neat.rules.models.entities import (
46
46
 
47
47
 
48
48
  class DMSImporter(BaseImporter):
49
+ """Imports a Data Model from Cognite Data Fusion.
50
+
51
+ Args:
52
+ schema: The schema containing the data model.
53
+ read_issues: A list of issues that occurred during the import.
54
+ metadata: Metadata for the data model.
55
+ ref_metadata: Metadata for the reference data model.
56
+
57
+ """
58
+
49
59
  def __init__(
50
60
  self,
51
61
  schema: DMSSchema,
@@ -18,7 +18,7 @@ from cognite.neat.utils.text import to_pascal
18
18
 
19
19
 
20
20
  class DTDLImporter(BaseImporter):
21
- """Importer for DTDL (Digital Twin Definition Language).
21
+ """Importer from Azure Digital Twin - DTDL (Digital Twin Definition Language).
22
22
 
23
23
  This importer supports DTDL v2.0 and v3.0.
24
24
 
@@ -33,7 +33,11 @@ INSTANCE_PROPERTIES_DEFINITION = """SELECT ?property (count(?property) as ?occur
33
33
 
34
34
 
35
35
  class InferenceImporter(BaseImporter):
36
- """Rules inference through analysis of knowledge graph provided in various formats.
36
+ """Infers rules from a triple store.
37
+
38
+ Rules inference through analysis of knowledge graph provided in various formats.
39
+ Use the factory methods to create an triples store from sources such as
40
+ RDF files, JSON files, YAML files, XML files, or directly from a graph store.
37
41
 
38
42
  Args:
39
43
  issue_list: Issue list to store issues
@@ -205,6 +205,12 @@ class SpreadsheetReader:
205
205
 
206
206
 
207
207
  class ExcelImporter(BaseImporter):
208
+ """Import rules from an Excel file.
209
+
210
+ Args:
211
+ filepath (Path): The path to the Excel file.
212
+ """
213
+
208
214
  def __init__(self, filepath: Path):
209
215
  self.filepath = filepath
210
216
 
@@ -286,6 +292,17 @@ class ExcelImporter(BaseImporter):
286
292
 
287
293
 
288
294
  class GoogleSheetImporter(BaseImporter):
295
+ """Import rules from a Google Sheet.
296
+
297
+ .. warning::
298
+
299
+ This importer is experimental and may not work as expected.
300
+
301
+ Args:
302
+ sheet_id (str): The Google Sheet ID.
303
+ skiprows (int): The number of rows to skip when reading the Google Sheet.
304
+ """
305
+
289
306
  def __init__(self, sheet_id: str, skiprows: int = 1):
290
307
  self.sheet_id = sheet_id
291
308
  self.skiprows = skiprows
@@ -6,7 +6,7 @@ from collections import Counter
6
6
  from functools import total_ordering
7
7
  from typing import ClassVar, Literal
8
8
 
9
- from pydantic import BaseModel, field_validator
9
+ from pydantic import BaseModel, field_validator, model_serializer
10
10
 
11
11
  from cognite.neat.rules import exceptions
12
12
 
@@ -82,6 +82,7 @@ TABLE_REGEX_COMPILED = re.compile(
82
82
 
83
83
  StepDirection = Literal["source", "target", "origin"]
84
84
  _direction_by_symbol: dict[str, StepDirection] = {"->": "target", "<-": "source"}
85
+ _symbol_by_direction: dict[StepDirection, str] = {"source": "<-", "target": "->"}
85
86
 
86
87
  Undefined = type(object())
87
88
  Unknown = type(object())
@@ -196,10 +197,29 @@ class Step(BaseModel):
196
197
  msg += " ->prefix:suffix, <-prefix:suffix, ->prefix:suffix(prefix:suffix) or <-prefix:suffix(prefix:suffix)"
197
198
  raise ValueError(msg)
198
199
 
200
+ def __str__(self) -> str:
201
+ if self.property:
202
+ return f"{self.class_}({self.property})"
203
+ else:
204
+ return f"{_symbol_by_direction[self.direction]}{self.class_}"
205
+
206
+ def __repr__(self) -> str:
207
+ return self.__str__()
208
+
199
209
 
200
210
  class Traversal(BaseModel):
201
211
  class_: Entity
202
212
 
213
+ def __str__(self) -> str:
214
+ return f"{self.class_}"
215
+
216
+ def __repr__(self) -> str:
217
+ return self.__str__()
218
+
219
+ @model_serializer(when_used="unless-none", return_type=str)
220
+ def as_str(self) -> str:
221
+ return str(self)
222
+
203
223
 
204
224
  class SingleProperty(Traversal):
205
225
  property: Entity
@@ -208,6 +228,9 @@ class SingleProperty(Traversal):
208
228
  def from_string(cls, class_: str, property_: str) -> Self:
209
229
  return cls(class_=Entity.from_string(class_), property=Entity.from_string(property_))
210
230
 
231
+ def __str__(self) -> str:
232
+ return f"{self.class_}({self.property})"
233
+
211
234
 
212
235
  class AllReferences(Traversal):
213
236
  @classmethod
@@ -220,6 +243,9 @@ class AllProperties(Traversal):
220
243
  def from_string(cls, class_: str) -> Self:
221
244
  return cls(class_=Entity.from_string(class_))
222
245
 
246
+ def __str__(self) -> str:
247
+ return f"{self.class_}(*)"
248
+
223
249
 
224
250
  class Origin(BaseModel):
225
251
  class_: Entity
@@ -245,6 +271,9 @@ class Hop(Traversal):
245
271
  ),
246
272
  )
247
273
 
274
+ def __str__(self) -> str:
275
+ return f"{self.class_}{''.join([str(step) for step in self.traversal])}"
276
+
248
277
 
249
278
  class TableLookup(BaseModel):
250
279
  name: str
@@ -261,7 +290,17 @@ class Query(BaseModel):
261
290
 
262
291
 
263
292
  class RDFPath(Rule):
264
- traversal: Traversal | Query
293
+ traversal: SingleProperty | AllProperties | AllReferences | Hop
294
+
295
+ def __str__(self) -> str:
296
+ return f"{self.traversal}"
297
+
298
+ def __repr__(self) -> str:
299
+ return self.__str__()
300
+
301
+ @model_serializer(when_used="unless-none", return_type=str)
302
+ def as_str(self) -> str:
303
+ return str(self)
265
304
 
266
305
 
267
306
  class RawLookup(RDFPath):
@@ -148,9 +148,12 @@ class AssetRules(BaseRules):
148
148
  def as_domain_rules(self) -> DomainRules:
149
149
  from ._converter import _AssetRulesConverter
150
150
 
151
- return _AssetRulesConverter(cast(InformationRules, self)).as_domain_rules()
151
+ return _AssetRulesConverter(self.as_information_architect_rules()).as_domain_rules()
152
152
 
153
153
  def as_dms_architect_rules(self) -> "DMSRules":
154
154
  from ._converter import _AssetRulesConverter
155
155
 
156
- return _AssetRulesConverter(cast(InformationRules, self)).as_dms_architect_rules()
156
+ return _AssetRulesConverter(self.as_information_architect_rules()).as_dms_architect_rules()
157
+
158
+ def as_information_architect_rules(self) -> InformationRules:
159
+ return InformationRules.model_validate(self.model_dump())
@@ -16,11 +16,15 @@ from cognite.neat.rules.models._constants import DMS_CONTAINER_SIZE_LIMIT
16
16
  from cognite.neat.rules.models.data_types import DataType
17
17
  from cognite.neat.rules.models.domain import DomainRules
18
18
  from cognite.neat.rules.models.entities import (
19
+ AssetEntity,
20
+ AssetFields,
19
21
  ClassEntity,
20
22
  ContainerEntity,
21
23
  DMSUnknownEntity,
24
+ EntityTypes,
22
25
  MultiValueTypeInfo,
23
26
  ReferenceEntity,
27
+ RelationshipEntity,
24
28
  UnknownEntity,
25
29
  ViewEntity,
26
30
  ViewPropertyEntity,
@@ -29,6 +33,7 @@ from cognite.neat.rules.models.entities import (
29
33
  from ._rules import InformationClass, InformationMetadata, InformationProperty, InformationRules
30
34
 
31
35
  if TYPE_CHECKING:
36
+ from cognite.neat.rules.models.asset._rules import AssetRules
32
37
  from cognite.neat.rules.models.dms._rules import DMSMetadata, DMSProperty, DMSRules
33
38
 
34
39
 
@@ -52,6 +57,28 @@ class _InformationRulesConverter:
52
57
  def as_domain_rules(self) -> DomainRules:
53
58
  raise NotImplementedError("DomainRules not implemented yet")
54
59
 
60
+ def as_asset_architect_rules(self) -> "AssetRules":
61
+ from cognite.neat.rules.models.asset._rules import AssetClass, AssetMetadata, AssetProperty, AssetRules
62
+
63
+ classes: SheetList[AssetClass] = SheetList[AssetClass](
64
+ data=[AssetClass(**class_.model_dump()) for class_ in self.rules.classes]
65
+ )
66
+ properties: SheetList[AssetProperty] = SheetList[AssetProperty]()
67
+ for prop_ in self.rules.properties:
68
+ if prop_.type_ == EntityTypes.data_property:
69
+ properties.append(
70
+ AssetProperty(**prop_.model_dump(), implementation=[AssetEntity(property=AssetFields.metadata)])
71
+ )
72
+ elif prop_.type_ == EntityTypes.object_property:
73
+ properties.append(AssetProperty(**prop_.model_dump(), implementation=[RelationshipEntity()]))
74
+
75
+ return AssetRules(
76
+ metadata=AssetMetadata(**self.rules.metadata.model_dump()),
77
+ properties=properties,
78
+ classes=classes,
79
+ prefixes=self.rules.prefixes,
80
+ )
81
+
55
82
  def as_dms_architect_rules(self) -> "DMSRules":
56
83
  from cognite.neat.rules.models.dms._rules import (
57
84
  DMSContainer,
@@ -49,7 +49,7 @@ from cognite.neat.rules.models.entities import (
49
49
  )
50
50
 
51
51
  if TYPE_CHECKING:
52
- from cognite.neat.rules.models.dms._rules import DMSRules
52
+ from cognite.neat.rules.models import AssetRules, DMSRules
53
53
 
54
54
 
55
55
  if sys.version_info >= (3, 11):
@@ -341,6 +341,11 @@ class InformationRules(BaseRules):
341
341
 
342
342
  return _InformationRulesConverter(self).as_domain_rules()
343
343
 
344
+ def as_asset_architect_rules(self) -> "AssetRules":
345
+ from ._converter import _InformationRulesConverter
346
+
347
+ return _InformationRulesConverter(self).as_asset_architect_rules()
348
+
344
349
  def as_dms_architect_rules(self) -> "DMSRules":
345
350
  from ._converter import _InformationRulesConverter
346
351
 
@@ -0,0 +1,298 @@
1
+ import os
2
+ import subprocess
3
+ from contextlib import suppress
4
+ from dataclasses import dataclass, fields
5
+ from pathlib import Path
6
+ from typing import Literal, TypeAlias, get_args
7
+
8
+ from cognite.client import CogniteClient
9
+ from cognite.client.credentials import CredentialProvider, OAuthClientCredentials, OAuthInteractive, Token
10
+
11
+ from cognite.neat import _version
12
+ from cognite.neat.utils.auxiliary import local_import
13
+
14
+ __all__ = ["get_cognite_client"]
15
+
16
+ _LOGIN_FLOW: TypeAlias = Literal["infer", "client_credentials", "interactive", "token"]
17
+ _VALID_LOGIN_FLOWS = get_args(_LOGIN_FLOW)
18
+ _CLIENT_NAME = f"CogniteNeat:{_version.__version__}"
19
+
20
+
21
+ def get_cognite_client() -> CogniteClient:
22
+ with suppress(KeyError):
23
+ variables = _EnvironmentVariables.create_from_environ()
24
+ return variables.get_client()
25
+
26
+ repo_root = _repo_root()
27
+ if repo_root:
28
+ with suppress(KeyError, FileNotFoundError, TypeError):
29
+ variables = _from_dotenv(repo_root / ".env")
30
+ client = variables.get_client()
31
+ print("Found .env file in repository root. Loaded variables from .env file.")
32
+ return client
33
+ variables = _prompt_user()
34
+ if repo_root and _env_in_gitignore(repo_root):
35
+ local_import("rich", "jupyter")
36
+ from rich.prompt import Prompt
37
+
38
+ env_file = repo_root / ".env"
39
+ answer = Prompt.ask(
40
+ "Do you store the variables in an .env file in the repository root for easy reuse?", choices=["y", "n"]
41
+ )
42
+ if env_file.exists():
43
+ answer = Prompt.ask(f"{env_file} already exists. Overwrite?", choices=["y", "n"])
44
+ if answer == "y":
45
+ env_file.write_text(variables.create_env_file())
46
+ print("Created .env file in repository root.")
47
+
48
+ return variables.get_client()
49
+
50
+
51
+ @dataclass
52
+ class _EnvironmentVariables:
53
+ CDF_CLUSTER: str
54
+ CDF_PROJECT: str
55
+ LOGIN_FLOW: _LOGIN_FLOW = "infer"
56
+ IDP_CLIENT_ID: str | None = None
57
+ IDP_CLIENT_SECRET: str | None = None
58
+ TOKEN: str | None = None
59
+
60
+ IDP_TENANT_ID: str | None = None
61
+ IDP_TOKEN_URL: str | None = None
62
+
63
+ CDF_URL: str | None = None
64
+ IDP_AUDIENCE: str | None = None
65
+ IDP_SCOPES: str | None = None
66
+ IDP_AUTHORITY_URL: str | None = None
67
+
68
+ def __post_init__(self):
69
+ if self.LOGIN_FLOW.lower() not in _VALID_LOGIN_FLOWS:
70
+ raise ValueError(f"LOGIN_FLOW must be one of {_VALID_LOGIN_FLOWS}")
71
+
72
+ @property
73
+ def cdf_url(self) -> str:
74
+ return self.CDF_URL or f"https://{self.CDF_CLUSTER}.cognitedata.com"
75
+
76
+ @property
77
+ def idp_token_url(self) -> str:
78
+ if self.IDP_TOKEN_URL:
79
+ return self.IDP_TOKEN_URL
80
+ if not self.IDP_TENANT_ID:
81
+ raise KeyError("IDP_TENANT_ID or IDP_TOKEN_URL must be set in the environment.")
82
+ return f"https://login.microsoftonline.com/{self.IDP_TENANT_ID}/oauth2/v2.0/token"
83
+
84
+ @property
85
+ def idp_audience(self) -> str:
86
+ return self.IDP_AUDIENCE or f"https://{self.CDF_CLUSTER}.cognitedata.com"
87
+
88
+ @property
89
+ def idp_scopes(self) -> list[str]:
90
+ if self.IDP_SCOPES:
91
+ return self.IDP_SCOPES.split()
92
+ return [f"https://{self.CDF_CLUSTER}.cognitedata.com/.default"]
93
+
94
+ @property
95
+ def idp_authority_url(self) -> str:
96
+ if self.IDP_AUTHORITY_URL:
97
+ return self.IDP_AUTHORITY_URL
98
+ if not self.IDP_TENANT_ID:
99
+ raise KeyError("IDP_TENANT_ID or IDP_AUTHORITY_URL must be set in the environment.")
100
+ return f"https://login.microsoftonline.com/{self.IDP_TENANT_ID}"
101
+
102
+ @classmethod
103
+ def create_from_environ(cls) -> "_EnvironmentVariables":
104
+ if "CDF_CLUSTER" not in os.environ or "CDF_PROJECT" not in os.environ:
105
+ raise KeyError("CDF_CLUSTER and CDF_PROJECT must be set in the environment.", "CDF_CLUSTER", "CDF_PROJECT")
106
+
107
+ return cls(
108
+ CDF_CLUSTER=os.environ["CDF_CLUSTER"],
109
+ CDF_PROJECT=os.environ["CDF_PROJECT"],
110
+ LOGIN_FLOW=os.environ.get("LOGIN_FLOW", "infer"), # type: ignore[arg-type]
111
+ IDP_CLIENT_ID=os.environ.get("IDP_CLIENT_ID"),
112
+ IDP_CLIENT_SECRET=os.environ.get("IDP_CLIENT_SECRET"),
113
+ TOKEN=os.environ.get("TOKEN"),
114
+ CDF_URL=os.environ.get("CDF_URL"),
115
+ IDP_TOKEN_URL=os.environ.get("IDP_TOKEN_URL"),
116
+ IDP_TENANT_ID=os.environ.get("IDP_TENANT_ID"),
117
+ IDP_AUDIENCE=os.environ.get("IDP_AUDIENCE"),
118
+ IDP_SCOPES=os.environ.get("IDP_SCOPES"),
119
+ IDP_AUTHORITY_URL=os.environ.get("IDP_AUTHORITY_URL"),
120
+ )
121
+
122
+ def get_credentials(self) -> CredentialProvider:
123
+ method_by_flow = {
124
+ "client_credentials": self.get_oauth_client_credentials,
125
+ "interactive": self.get_oauth_interactive,
126
+ "token": self.get_token,
127
+ }
128
+ if self.LOGIN_FLOW in method_by_flow:
129
+ return method_by_flow[self.LOGIN_FLOW]()
130
+ key_options: list[tuple[str, ...]] = []
131
+ for method in method_by_flow.values():
132
+ try:
133
+ return method()
134
+ except KeyError as e:
135
+ key_options += e.args[1:]
136
+ raise KeyError(
137
+ f"LOGIN_FLOW={self.LOGIN_FLOW} requires one of the following environment set variables to be set.",
138
+ *key_options,
139
+ )
140
+
141
+ def get_oauth_client_credentials(self) -> OAuthClientCredentials:
142
+ if not self.IDP_CLIENT_ID or not self.IDP_CLIENT_SECRET:
143
+ raise KeyError(
144
+ "IDP_CLIENT_ID and IDP_CLIENT_SECRET must be set in the environment.",
145
+ "IDP_CLIENT_ID",
146
+ "IDP_CLIENT_SECRET",
147
+ )
148
+ return OAuthClientCredentials(
149
+ client_id=self.IDP_CLIENT_ID,
150
+ client_secret=self.IDP_CLIENT_SECRET,
151
+ token_url=self.idp_token_url,
152
+ audience=self.idp_audience,
153
+ scopes=self.idp_scopes,
154
+ )
155
+
156
+ def get_oauth_interactive(self) -> OAuthInteractive:
157
+ if not self.IDP_CLIENT_ID:
158
+ raise KeyError("IDP_CLIENT_ID must be set in the environment.", "IDP_CLIENT_ID")
159
+ return OAuthInteractive(
160
+ client_id=self.IDP_CLIENT_ID,
161
+ authority_url=self.idp_authority_url,
162
+ redirect_port=53_000,
163
+ scopes=self.idp_scopes,
164
+ )
165
+
166
+ def get_token(self) -> Token:
167
+ if not self.TOKEN:
168
+ raise KeyError("TOKEN must be set in the environment", "TOKEN")
169
+ return Token(self.TOKEN)
170
+
171
+ def get_client(self) -> CogniteClient:
172
+ return CogniteClient.default(
173
+ self.CDF_PROJECT, self.CDF_CLUSTER, credentials=self.get_credentials(), client_name=_CLIENT_NAME
174
+ )
175
+
176
+ def create_env_file(self) -> str:
177
+ lines: list[str] = []
178
+ first_optional = True
179
+ for field in fields(self):
180
+ is_optional = hasattr(self, field.name.lower())
181
+ if is_optional and first_optional:
182
+ lines.append(
183
+ "# The below variables are the defaults, they are automatically " "constructed unless they are set."
184
+ )
185
+ first_optional = False
186
+ name = field.name.lower() if is_optional else field.name
187
+ value = getattr(self, name)
188
+ if value is not None:
189
+ if isinstance(value, list):
190
+ value = " ".join(value)
191
+ lines.append(f"{field.name}={value}")
192
+ return "\n".join(lines)
193
+
194
+
195
+ def _from_dotenv(evn_file: Path) -> _EnvironmentVariables:
196
+ if not evn_file.exists():
197
+ raise FileNotFoundError(f"{evn_file} does not exist.")
198
+ content = evn_file.read_text()
199
+ valid_variables = {f.name for f in fields(_EnvironmentVariables)}
200
+ variables: dict[str, str] = {}
201
+ for line in content.splitlines():
202
+ if line.startswith("#") or not line:
203
+ continue
204
+ key, value = line.split("=", 1)
205
+ if key in valid_variables:
206
+ variables[key] = value
207
+ return _EnvironmentVariables(**variables) # type: ignore[arg-type]
208
+
209
+
210
+ def _prompt_user() -> _EnvironmentVariables:
211
+ local_import("rich", "jupyter")
212
+ from rich.prompt import Prompt
213
+
214
+ try:
215
+ variables = _EnvironmentVariables.create_from_environ()
216
+ continue_ = Prompt.ask(
217
+ f"Use environment variables for CDF Cluster '{variables.CDF_CLUSTER}' "
218
+ f"and Project '{variables.CDF_PROJECT}'? [y/n]",
219
+ choices=["y", "n"],
220
+ default="y",
221
+ )
222
+ if continue_ == "n":
223
+ variables = _prompt_cluster_and_project()
224
+ except KeyError:
225
+ variables = _prompt_cluster_and_project()
226
+
227
+ login_flow = Prompt.ask("Login flow", choices=[f for f in _VALID_LOGIN_FLOWS if f != "infer"])
228
+ variables.LOGIN_FLOW = login_flow # type: ignore[assignment]
229
+ if login_flow == "token":
230
+ token = Prompt.ask("Enter token")
231
+ variables.TOKEN = token
232
+ return variables
233
+
234
+ variables.IDP_CLIENT_ID = Prompt.ask("Enter IDP Client ID")
235
+ if login_flow == "client_credentials":
236
+ variables.IDP_CLIENT_SECRET = Prompt.ask("Enter IDP Client Secret", password=True)
237
+ tenant_id = Prompt.ask("Enter IDP_TENANT_ID (leave empty to enter IDP_TOKEN_URL instead)")
238
+ if tenant_id:
239
+ variables.IDP_TENANT_ID = tenant_id
240
+ else:
241
+ token_url = Prompt.ask("Enter IDP_TOKEN_URL")
242
+ variables.IDP_TOKEN_URL = token_url
243
+ optional = ["IDP_AUDIENCE", "IDP_SCOPES"]
244
+ else:
245
+ optional = ["IDP_TENANT_ID", "IDP_SCOPES"]
246
+
247
+ defaults = "".join(f"\n - {name}: {getattr(variables, name.lower())}" for name in optional)
248
+ use_defaults = Prompt.ask(
249
+ f"Use default values for the following variables?{defaults}", choices=["y", "n"], default="y"
250
+ )
251
+ if use_defaults:
252
+ return variables
253
+ for name in optional:
254
+ value = Prompt.ask(f"Enter {name}")
255
+ setattr(variables, name, value)
256
+ return variables
257
+
258
+
259
+ def _prompt_cluster_and_project() -> _EnvironmentVariables:
260
+ from rich.prompt import Prompt
261
+
262
+ cluster = Prompt.ask("Enter CDF Cluster (example 'greenfield', 'bluefield', 'westeurope-1)")
263
+ project = Prompt.ask("Enter CDF Project")
264
+ return _EnvironmentVariables(cluster, project)
265
+
266
+
267
+ def _is_notebook() -> bool:
268
+ try:
269
+ shell = get_ipython().__class__.__name__ # type: ignore[name-defined]
270
+ if shell == "ZMQInteractiveShell":
271
+ return True # Jupyter notebook or qtconsole
272
+ elif shell == "TerminalInteractiveShell":
273
+ return False # Terminal running IPython
274
+ else:
275
+ return False # Other type (?)
276
+ except NameError:
277
+ return False # Probably standard Python interpreter
278
+
279
+
280
+ def _repo_root() -> Path | None:
281
+ with suppress(Exception):
282
+ result = subprocess.run("git rev-parse --show-toplevel".split(), stdout=subprocess.PIPE)
283
+ return Path(result.stdout.decode().strip())
284
+ return None
285
+
286
+
287
+ def _env_in_gitignore(repo_root: Path) -> bool:
288
+ ignore_file = repo_root / ".gitignore"
289
+ if not ignore_file.exists():
290
+ return False
291
+ else:
292
+ ignored = {line.strip() for line in ignore_file.read_text().splitlines()}
293
+ return ".env" in ignored or "*.env" in ignored
294
+
295
+
296
+ if __name__ == "__main__":
297
+ c = _prompt_user().get_client()
298
+ print(c.iam.token.inspect())