brynq-sdk-brynq 4.0.4__tar.gz → 4.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of brynq-sdk-brynq might be problematic. Click here for more details.
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/PKG-INFO +1 -1
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/interfaces.py +3 -47
- brynq_sdk_brynq-4.0.8/brynq_sdk_brynq/scenarios.py +482 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/__init__.py +4 -2
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/interfaces.py +0 -52
- brynq_sdk_brynq-4.0.8/brynq_sdk_brynq/schemas/scenarios.py +437 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/PKG-INFO +1 -1
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/SOURCES.txt +2 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/setup.py +2 -2
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/__init__.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/brynq.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/credentials.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/customers.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/mappings.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/organization_chart.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/roles.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/credentials.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/customers.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/organization_chart.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/roles.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/users.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/source_systems.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/users.py +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/dependency_links.txt +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/not-zip-safe +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/requires.txt +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/top_level.txt +0 -0
- {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/setup.cfg +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from typing import Dict, List, Any, Optional
|
|
2
2
|
from .credentials import Credentials
|
|
3
3
|
from .mappings import Mappings
|
|
4
|
-
from .
|
|
4
|
+
from .scenarios import Scenarios
|
|
5
|
+
from .schemas.interfaces import Interface, InterfaceDetail, InterfaceConfig, Schedule, Scope, DevSettings
|
|
5
6
|
from brynq_sdk_functions import Functions
|
|
6
7
|
|
|
7
8
|
class Interfaces:
|
|
@@ -18,6 +19,7 @@ class Interfaces:
|
|
|
18
19
|
self._brynq = brynq_instance
|
|
19
20
|
self.credentials = Credentials(brynq_instance)
|
|
20
21
|
self.mappings = Mappings(brynq_instance)
|
|
22
|
+
self.scenarios = Scenarios(brynq_instance)
|
|
21
23
|
|
|
22
24
|
def get_all(self) -> List[Dict[str, Any]]:
|
|
23
25
|
"""Get all interfaces this token has access to.
|
|
@@ -156,22 +158,6 @@ class Interfaces:
|
|
|
156
158
|
except ValueError as e:
|
|
157
159
|
raise ValueError(f"Invalid schedule configuration data: {str(e)}")
|
|
158
160
|
|
|
159
|
-
def get_template_config(self) -> Dict[str, Any]:
|
|
160
|
-
"""
|
|
161
|
-
Get the template configuration of an interface.
|
|
162
|
-
|
|
163
|
-
Returns:
|
|
164
|
-
Dict[str, Any]: Template configuration
|
|
165
|
-
|
|
166
|
-
Raises:
|
|
167
|
-
requests.exceptions.RequestException: If the API request fails
|
|
168
|
-
"""
|
|
169
|
-
response = self._brynq.brynq_session.get(
|
|
170
|
-
url=f'{self._brynq.url}interfaces/{self._brynq.data_interface_id}/template-config',
|
|
171
|
-
timeout=self._brynq.timeout
|
|
172
|
-
)
|
|
173
|
-
response.raise_for_status()
|
|
174
|
-
return response.json()
|
|
175
161
|
|
|
176
162
|
def get_scope(self) -> Dict[str, Any]:
|
|
177
163
|
"""Get live and draft scopes from interface by id.
|
|
@@ -246,33 +232,3 @@ class Interfaces:
|
|
|
246
232
|
else:
|
|
247
233
|
self.flush_config()
|
|
248
234
|
return variables
|
|
249
|
-
|
|
250
|
-
def get_scenario(self, strict: bool = True) -> List[Dict[str, Any]]:
|
|
251
|
-
"""
|
|
252
|
-
Get the scenarios of an interface.
|
|
253
|
-
|
|
254
|
-
Args:
|
|
255
|
-
strict (bool): If True, raises an error when all scenario records fail validation.
|
|
256
|
-
|
|
257
|
-
Returns:
|
|
258
|
-
List[Dict[str, Any]]: List of scenario configurations.
|
|
259
|
-
"""
|
|
260
|
-
response = self._brynq.brynq_session.get(
|
|
261
|
-
url=f"{self._brynq.url}interfaces/{self._brynq.data_interface_id}/scenarios",
|
|
262
|
-
timeout=self._brynq.timeout
|
|
263
|
-
)
|
|
264
|
-
response.raise_for_status()
|
|
265
|
-
|
|
266
|
-
try:
|
|
267
|
-
scenario_data = response.json()
|
|
268
|
-
valid_data, invalid_data = Functions.validate_pydantic_data(
|
|
269
|
-
scenario_data, schema=Scenario, debug=True
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
if strict and not valid_data and invalid_data:
|
|
273
|
-
raise ValueError(f"Invalid scenario data, see debug for more information")
|
|
274
|
-
|
|
275
|
-
return valid_data
|
|
276
|
-
|
|
277
|
-
except ValueError as e:
|
|
278
|
-
raise ValueError(f"Invalid scenario data: {str(e)}")
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scenarios sdk for BrynQ.
|
|
3
|
+
This module provides a class for managing scenarios in BrynQ, allowing you to fetch and parse scenarios from the BrynQ API,
|
|
4
|
+
|
|
5
|
+
In the BrynQ frontend, there is a scenarios tab in the interfaces of customers, e.g. https://ssc.app.brynq.com/interfaces/19/scenarios (id may differ).
|
|
6
|
+
This tab contains scenarios like 'Personal information' or 'Adres'.
|
|
7
|
+
In each scenario, you can see the fields that are available, how they map from source to target, and its properties (unique, required, etc.)
|
|
8
|
+
|
|
9
|
+
General usage
|
|
10
|
+
- The (parsed) scenario object can be accessed as a dictionary*, e.g. self.scenarios['Personal information'].
|
|
11
|
+
*Why dict? -> name of the scenario is often not python friendly.
|
|
12
|
+
|
|
13
|
+
- The parsed scenario object attributes such as scenario properties, its fields and the properties of those fields can be accessed using either dict or dot notation**:
|
|
14
|
+
e.g. self.scenarios['Personal information'].surname.required == self.scenarios['Personal information']['surname'].required
|
|
15
|
+
**why both dict and dot notation? -> follow pandas workflow, with same access methods. scenario can be considered 'the dataframe', and the fields the 'columns'. so df.column and df['column'] are the same.
|
|
16
|
+
|
|
17
|
+
# Example usage
|
|
18
|
+
We can use the Taskscheduler to access this data, as it inherits from BrynQ and thus the interfaces object (and thus the scenarios object ...)
|
|
19
|
+
below you can see setup of getting scenario data and some example usage.
|
|
20
|
+
|
|
21
|
+
class Koppeling(TaskScheduler):
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__(data_interface_id=19)
|
|
24
|
+
self.scenarios = self.brynq.interfaces.scenarios.get()
|
|
25
|
+
|
|
26
|
+
def run(self):
|
|
27
|
+
#most general methods that operate on all scenarios
|
|
28
|
+
self.scenarios.find_scenarios_with_field(field_name = 'employee_id') #returns empty (a list of scenarios that contain the field 'employee_id', defaults to source)
|
|
29
|
+
self.scenarios.find_scenarios_with_field(field_name = 'employee_id', field_type = 'target') #returns [<ParsedScenario name='Personal information' id='3c7f8e04-5b74-408f-a2d8-ad99b924a1af' details=15 unique=2 required=20>, <ParsedScenario name='Adres' (...)]
|
|
30
|
+
self.scenarios.scenario_names #['Personal information', 'Adres', 'Bank Account', 'Contract Information', (...)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#base parsed scenario attributes, which are dynamically made into python objects.
|
|
34
|
+
#scenario level attributes
|
|
35
|
+
self.scenarios['Personal information'] #returns a ParsedScenario object <ParsedScenario name='Personal information' id='3c7f8e04-5b74-408f-a2d8-ad99b924a1af' details=15 unique=2 required=20>
|
|
36
|
+
self.scenarios['Personal information'].name #returns 'Personal information'
|
|
37
|
+
self.scenarios['Personal information'].id #returns '3c7f8e04-5b74-408f-a2d8-ad99b924a1af'
|
|
38
|
+
self.scenarios['Personal information'].details_count #returns 15
|
|
39
|
+
self.scenarios['Personal information'].source_to_target_map #returns {'work.employeeIdInCompany': [], 'root.firstName': ['firstname'], 'root.surname': ['lastname'], (...)}
|
|
40
|
+
self.scenarios['Personal information'].target_to_source_map #returns {'employee_id': [], 'firstname': ['root.firstName'], 'lastname': ['root.surname'], (...)}
|
|
41
|
+
self.scenarios['Personal information'].field_properties #returns {'employee_id': FieldProperties(logic='', unique=True, required=True, mapping={'values': [], 'default...a': ['personal_information-employee_id']}), 'work.employeeIdInCompany': FieldProperties(logic='Field is only used to detect new employees and not send to Dat...}, target={'type': 'LIBRARY', 'data': []}), (...)}
|
|
42
|
+
self.scenarios['Personal information'].unique_fields #returns ['employee_id', 'work.employeeIdInCompany']
|
|
43
|
+
self.scenarios['Personal information'].required_fields #returns ['employee_id', 'work.employeeIdInCompany', 'root.firstName', 'firstname', 'root.surname',(...)]
|
|
44
|
+
|
|
45
|
+
#field level attributes (accesible via the field property class; logic, unique, required, mapping, system_type)
|
|
46
|
+
self.scenarios['Personal information']['root.firstName'].required #returns True
|
|
47
|
+
self.brynq.interfaces.scenarios['Personal information'].firstname.required #returns True
|
|
48
|
+
self.scenarios['Personal information']['root.firstName'].unique #returns False
|
|
49
|
+
self.scenarios['Personal information'].get_mapped_field_names('root.surname') #returns ['lastname']
|
|
50
|
+
|
|
51
|
+
#dunder methods (python object structure- e.g. calling len, iterate over object, etc)
|
|
52
|
+
n_of_scenarios = len(self.scenarios) #returns 13
|
|
53
|
+
|
|
54
|
+
#iterate over scenarios
|
|
55
|
+
for scenario in self.scenarios:
|
|
56
|
+
print(scenario.name) #returns 'Personal information', 'Adres', ...
|
|
57
|
+
print(scenario.id) #returns '3c7f8e04-5b74-408f-a2d8-ad99b924a1af', 'c20dfca5-d30e-4f57-bfe9-8c9fc7a956a9', ...
|
|
58
|
+
|
|
59
|
+
#anything useful u can get from the scenario object (example returns are from 'Adres' scenario, but are in reality for each scenario retrieved as you iterate)
|
|
60
|
+
scenario.source_to_target_map #returns {'address.line1': ['house_number', 'street'], 'address.postCode': ['postalcode', 'postalcode_foreign_country'], 'address.city': ['city'], 'address.line2': ['supplement'], 'address_country': ['country']}
|
|
61
|
+
all_source_fields = scenario.all_source_fields #returns {'address.line2', 'address.line1', 'address.postCode', 'address.city', 'address_country'}
|
|
62
|
+
all_target_fields = scenario.all_target_fields #returns {'employee_id', 'city', 'house_number', 'postalcode', 'supplement', 'postalcode_foreign_country', 'street', 'country'}
|
|
63
|
+
required_fields = scenario.required_fields #returns ['employee_id', 'address.line1', 'house_number', 'postalcode', 'address.city', 'city', 'street', 'address.line2', 'supplement', 'address_country', 'country']
|
|
64
|
+
unique_fields = scenario.unique_fields #returns ['employee_id', 'house_number', 'postalcode']
|
|
65
|
+
"""
|
|
66
|
+
# imports
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import re
|
|
70
|
+
import warnings
|
|
71
|
+
from collections import defaultdict
|
|
72
|
+
from functools import cached_property
|
|
73
|
+
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union
|
|
74
|
+
|
|
75
|
+
import pandas as pd
|
|
76
|
+
import requests
|
|
77
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
78
|
+
|
|
79
|
+
from brynq_sdk_functions import Functions
|
|
80
|
+
|
|
81
|
+
from .schemas.scenarios import FieldProperties, ParsedScenario, Scenario
|
|
82
|
+
|
|
83
|
+
# TOOD: Value mapping return string None if empty, should be a real None
|
|
84
|
+
# TODO: employee_id, id and person_id are always kept when renaming fields, but thats only relevant for HiBob.
|
|
85
|
+
|
|
86
|
+
class Scenarios():
|
|
87
|
+
"""
|
|
88
|
+
Provides convenient access to BrynQ scenarios, with lookups and a Pythonic interface.
|
|
89
|
+
"""
|
|
90
|
+
def __init__(self,
|
|
91
|
+
brynq_instance: Any
|
|
92
|
+
):
|
|
93
|
+
"""
|
|
94
|
+
Initialize the manager, automatically fetching and parsing all scenarios.
|
|
95
|
+
|
|
96
|
+
#example (direct) usage, usually will be inherited from BrynQ/TaskScheduler.
|
|
97
|
+
scenarios = Scenarios(brynq_instance)
|
|
98
|
+
scenarios.get()
|
|
99
|
+
scenarios.find_scenarios_with_field(field_name = 'employee_id')
|
|
100
|
+
scenarios['Personal information'].surname.required
|
|
101
|
+
scenarios['Personal information'].surname.unique
|
|
102
|
+
scenarios['Personal information'].surname.mapping
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
brynq_instance: An authenticated BrynQ client instance.
|
|
106
|
+
strict: If True, will raise an error on any data validation failure.
|
|
107
|
+
"""
|
|
108
|
+
self._brynq = brynq_instance
|
|
109
|
+
|
|
110
|
+
#attributes to be filled in get()
|
|
111
|
+
self.raw_scenarios: Optional[List[Dict]] = None
|
|
112
|
+
self.scenarios: Optional[List[ParsedScenario]] = None
|
|
113
|
+
self.source_system: Optional[Any] = None
|
|
114
|
+
self.source_sdk_config: Optional[Dict] = None
|
|
115
|
+
self.target_system = None
|
|
116
|
+
self.source_field_remove_prefix: Optional[Dict[str, str]] = None
|
|
117
|
+
|
|
118
|
+
#public methods
|
|
119
|
+
def get(self, strict: bool = True, setup_helper: bool = True) -> List[ParsedScenario]:
|
|
120
|
+
"""Fetches scenarios from the API, parses themand returns them as a list.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
strict (bool): If True, will raise an error on any data validation failure.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List[ParsedScenario]: A list of parsed scenario objects retrieved from the API.
|
|
127
|
+
"""
|
|
128
|
+
if self.scenarios is None:
|
|
129
|
+
self.raw_scenarios = self._fetch_from_api(strict=strict)
|
|
130
|
+
self.scenarios = [ParsedScenario.from_api_dict(
|
|
131
|
+
scenario=s,
|
|
132
|
+
source_sdk=self.source_system,
|
|
133
|
+
sdk_mapping_config=self.source_sdk_config,
|
|
134
|
+
source_field_remove_prefix=self.source_field_remove_prefix
|
|
135
|
+
)
|
|
136
|
+
for s in self.raw_scenarios if "name" in s]
|
|
137
|
+
return self.scenarios
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
#public convenience methods
|
|
141
|
+
def find_scenarios_with_field(
|
|
142
|
+
self, field_name: str, field_type: str = "source"
|
|
143
|
+
) -> List[ParsedScenario]:
|
|
144
|
+
"""Find all scenarios that contain a specific field.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
field_name (str): The name of the field to search for.
|
|
148
|
+
field_type (str, optional): The type of field to search in ("source" or "target"). Defaults to "source".
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List[ParsedScenario]: A list of scenarios containing the specified field.
|
|
152
|
+
"""
|
|
153
|
+
return [
|
|
154
|
+
p for p in self.get()
|
|
155
|
+
if p.has_field(field_name, field_type=field_type)
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
#(cached) properties - blijft hangen als attribute
|
|
159
|
+
@cached_property
|
|
160
|
+
def scenario_names(self) -> List[str]:
|
|
161
|
+
"""A list of all scenario names. call as attribute, e.g. self.scenarios.scenario_names"""
|
|
162
|
+
return [s.name for s in self.get()] if self.scenarios is not None else []
|
|
163
|
+
|
|
164
|
+
#dunder methods for pythonic access to raw scenarios
|
|
165
|
+
def __getitem__(self, scenario_name: str) -> ParsedScenario:
|
|
166
|
+
"""Enable dict-style access: `scenarios['Demo']`."""
|
|
167
|
+
scenarios = {s.name: s for s in self.get()}
|
|
168
|
+
if scenario_name not in scenarios:
|
|
169
|
+
raise KeyError(f"Scenario '{scenario_name}' not found.")
|
|
170
|
+
return scenarios[scenario_name]
|
|
171
|
+
|
|
172
|
+
def __iter__(self) -> Iterator[ParsedScenario]:
|
|
173
|
+
"""Iterate over the parsed scenarios."""
|
|
174
|
+
return iter(self.get())
|
|
175
|
+
|
|
176
|
+
def __len__(self) -> int:
|
|
177
|
+
"""Return the number of available scenarios."""
|
|
178
|
+
return len(self.get())
|
|
179
|
+
|
|
180
|
+
#internal helpers
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _to_attribute_name(name: str) -> str:
|
|
183
|
+
"""Converts a scenario name into a valid, snake_case Python attribute.
|
|
184
|
+
e.g. 'Personal information' -> 'personal_information'
|
|
185
|
+
Args:
|
|
186
|
+
name (str): The (scenario/field) name to convert.
|
|
187
|
+
Returns:
|
|
188
|
+
str: The snake_case, valid Python attribute name derived from the scenario name.
|
|
189
|
+
"""
|
|
190
|
+
#sub spaces and hyphens with an underscore
|
|
191
|
+
s = name.lower().replace(" ", "_").replace("-", "_")
|
|
192
|
+
#sub non-word characters with an empty string
|
|
193
|
+
s = re.sub(r"[^\w_]", "", s)
|
|
194
|
+
#sub consecutive underscores wiht a single underscore
|
|
195
|
+
s = re.sub(r"_+", "_", s)
|
|
196
|
+
#if the first character is a digit, add an underscore in front
|
|
197
|
+
if s and s[0].isdigit():
|
|
198
|
+
return f"_{s}"
|
|
199
|
+
return s
|
|
200
|
+
|
|
201
|
+
def _fetch_from_api(self, strict: bool = True) -> List[Dict[str, Any]]:
|
|
202
|
+
"""Retrieve and validate scenario payloads from the API as is.
|
|
203
|
+
Args:
|
|
204
|
+
strict (bool): If True, will raise an error on any data validation failure.
|
|
205
|
+
Returns:
|
|
206
|
+
List[Dict[str, Any]]: A list of scenario payloads retrieved from the API.
|
|
207
|
+
"""
|
|
208
|
+
response = self._brynq.brynq_session.get(
|
|
209
|
+
url=f"{self._brynq.url}interfaces/{self._brynq.data_interface_id}/scenarios",
|
|
210
|
+
timeout=self._brynq.timeout,
|
|
211
|
+
)
|
|
212
|
+
response.raise_for_status()
|
|
213
|
+
scenario_list = response.json()
|
|
214
|
+
if not isinstance(scenario_list, list):
|
|
215
|
+
raise TypeError(f"Expected a list of scenarios, but got {type(scenario_list).__name__}.")
|
|
216
|
+
|
|
217
|
+
valid_scenarios, invalid_scenarios = Functions.validate_pydantic_data(
|
|
218
|
+
scenario_list, schema=Scenario, debug=True
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if invalid_scenarios:
|
|
222
|
+
msg = f"{len(invalid_scenarios)} scenario(s) failed validation and were skipped."
|
|
223
|
+
if strict:
|
|
224
|
+
raise ValueError(f"Invalid scenario data found: {msg}")
|
|
225
|
+
warnings.warn(msg, UserWarning)
|
|
226
|
+
|
|
227
|
+
return valid_scenarios
|
|
228
|
+
|
|
229
|
+
#public helper functions to be used in project specific logic and require a dataframe with data of corresponding scenario.
|
|
230
|
+
def add_fixed_values(self, df: pd.DataFrame, scenario_name: str) -> pd.DataFrame:
|
|
231
|
+
"""
|
|
232
|
+
Add fixed values to the dataframe.
|
|
233
|
+
"""
|
|
234
|
+
df_with_fixed = df.copy()
|
|
235
|
+
scenario = None
|
|
236
|
+
for s in self.scenarios:
|
|
237
|
+
if getattr(s, "name", None) == scenario_name:
|
|
238
|
+
scenario = s
|
|
239
|
+
break
|
|
240
|
+
if scenario is None:
|
|
241
|
+
raise ValueError(f"Scenario with name '{scenario_name}' not found.")
|
|
242
|
+
for _, value in scenario.field_properties.items():
|
|
243
|
+
try:
|
|
244
|
+
source_type = value.source['type']
|
|
245
|
+
if source_type == 'FIXED':
|
|
246
|
+
source_value = value.source['data']
|
|
247
|
+
parts = value.target['data'][0].split('-')
|
|
248
|
+
if len(parts) > 1:
|
|
249
|
+
target_field = parts[-1] # Taking the last part is safest.
|
|
250
|
+
df_with_fixed[target_field] = source_value
|
|
251
|
+
else:
|
|
252
|
+
warnings.warn(f"Target field of {parts} not found in dataframe due to unexpected format of target field (should be sdkmethod-field).")
|
|
253
|
+
if target_field not in df_with_fixed.columns:
|
|
254
|
+
warnings.warn(f"Target field {target_field} not found in dataframe.")
|
|
255
|
+
except Exception as e:
|
|
256
|
+
warnings.warn(f"Error adding fixed value to dataframe: {e}")
|
|
257
|
+
continue
|
|
258
|
+
return df_with_fixed
|
|
259
|
+
|
|
260
|
+
def apply_value_mappings(self, df: pd.DataFrame, scenario_name: str, drop_unmapped: bool = False, fix_source_val: bool = True, fix_target_val: bool = True) -> Tuple[pd.DataFrame, Set[str]]:
|
|
261
|
+
"""
|
|
262
|
+
Apply value mappings from the scenario.
|
|
263
|
+
For example, maps 'F' to '1' and 'M' to '0' for gender.
|
|
264
|
+
Unmapped values are set to a default if provided, otherwise they remain unchanged.
|
|
265
|
+
Returns the modified dataframe and a set of pythonic source fields that were handled.
|
|
266
|
+
"""
|
|
267
|
+
scenario = None
|
|
268
|
+
# Find the correct scenario object based on its name
|
|
269
|
+
for s in self.scenarios:
|
|
270
|
+
if getattr(s, "name", None) == scenario_name:
|
|
271
|
+
scenario = s
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
# Exit early if the scenario has no value mappings to apply
|
|
275
|
+
if not hasattr(scenario, 'source_to_value_mappings') or not scenario.source_to_value_mappings:
|
|
276
|
+
return df, set()
|
|
277
|
+
|
|
278
|
+
source_to_value_mappings = scenario.source_to_value_mappings
|
|
279
|
+
alias_to_pythonic = scenario.alias_to_pythonic or {}
|
|
280
|
+
handled_source_fields = set()
|
|
281
|
+
|
|
282
|
+
# Process each source field that has value mappings
|
|
283
|
+
for source_field_alias, mappings in source_to_value_mappings.items():
|
|
284
|
+
# Handle multiple source fields joined with pipe symbol
|
|
285
|
+
source_field_aliases = source_field_alias.split('|')
|
|
286
|
+
source_field_aliases = sorted(source_field_aliases)
|
|
287
|
+
pythonic_source_fields = []
|
|
288
|
+
|
|
289
|
+
# Convert each source field alias to pythonic format and validate existence
|
|
290
|
+
all_fields_exist = True
|
|
291
|
+
for field_alias in source_field_aliases:
|
|
292
|
+
pythonic_field = alias_to_pythonic.get(field_alias, field_alias)
|
|
293
|
+
if pythonic_field not in df.columns and field_alias in df.columns:
|
|
294
|
+
# Dataframe already uses pythonic naming; fall back to the alias itself
|
|
295
|
+
pythonic_field = field_alias
|
|
296
|
+
if pythonic_field not in df.columns or pythonic_field is None:
|
|
297
|
+
warnings.warn(f"Source field {field_alias.strip()} not found in dataframe.")
|
|
298
|
+
all_fields_exist = False
|
|
299
|
+
break
|
|
300
|
+
pythonic_source_fields.append(pythonic_field)
|
|
301
|
+
|
|
302
|
+
# Skip if any of the source fields are missing
|
|
303
|
+
if not all_fields_exist:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Ensure all columns are of string type and track handled fields
|
|
307
|
+
for pythonic_field in pythonic_source_fields:
|
|
308
|
+
df[pythonic_field] = df[pythonic_field].astype(str)
|
|
309
|
+
handled_source_fields.add(pythonic_field)
|
|
310
|
+
|
|
311
|
+
# For mapping operations, we'll use the combined values if multiple fields
|
|
312
|
+
if len(pythonic_source_fields) == 1:
|
|
313
|
+
source_column_for_mapping = pythonic_source_fields[0]
|
|
314
|
+
else:
|
|
315
|
+
# Create a temporary combined column for mapping
|
|
316
|
+
combined_field_name = '|'.join(pythonic_source_fields)
|
|
317
|
+
df[combined_field_name] = df[pythonic_source_fields].apply(
|
|
318
|
+
lambda row: '|'.join(row.astype(str)), axis=1
|
|
319
|
+
)
|
|
320
|
+
source_column_for_mapping = combined_field_name
|
|
321
|
+
|
|
322
|
+
# Process each mapping rule (i.e., each target column to be created from this source)
|
|
323
|
+
for mapping in mappings:
|
|
324
|
+
# Build a dictionary of all replacements for this target column
|
|
325
|
+
replacements = {}
|
|
326
|
+
for mapping_value in mapping.values:
|
|
327
|
+
# This complex access is based on the provided JSON structure
|
|
328
|
+
source_map_val = mapping_value.input
|
|
329
|
+
target_map_val = mapping_value.output
|
|
330
|
+
|
|
331
|
+
# Handle different cases for source and target mapping
|
|
332
|
+
if source_map_val and target_map_val and len(target_map_val) == 1:
|
|
333
|
+
target_val = list(target_map_val.values())[0]
|
|
334
|
+
|
|
335
|
+
# Case 1: Single source field to single target field
|
|
336
|
+
if len(source_map_val) == 1:
|
|
337
|
+
source_val = list(source_map_val.values())[0]
|
|
338
|
+
|
|
339
|
+
# Clean the mapping values to handle different delimiters
|
|
340
|
+
if (fix_source_val) and ('-' in source_val or ':' in source_val):
|
|
341
|
+
source_val = source_val.replace('-', ':').split(':')[0]
|
|
342
|
+
if (fix_target_val) and ('-' in target_val or ':' in target_val):
|
|
343
|
+
target_val = target_val.replace('-', ':').split(':')[0]
|
|
344
|
+
|
|
345
|
+
replacements[source_val] = target_val
|
|
346
|
+
|
|
347
|
+
# Case 2: Multiple source fields to single target field
|
|
348
|
+
elif len(source_map_val) > 1 and len(pythonic_source_fields) > 1:
|
|
349
|
+
# Extract target field name using the same logic as later in the code
|
|
350
|
+
target_field = list(mapping.values[0].output)[0].split('-')[1]
|
|
351
|
+
|
|
352
|
+
# Transform keys in source_map_val using the target field logic
|
|
353
|
+
transformed_source_map = {}
|
|
354
|
+
for key, value in source_map_val.items():
|
|
355
|
+
# Apply the same transformation logic
|
|
356
|
+
transformed_key = key.split('-')[1] if '-' in key else key
|
|
357
|
+
transformed_source_map[transformed_key] = value
|
|
358
|
+
|
|
359
|
+
# Extract values from each source field in the mapping using transformed keys
|
|
360
|
+
source_values = []
|
|
361
|
+
for field_alias in source_field_aliases:
|
|
362
|
+
if field_alias in transformed_source_map:
|
|
363
|
+
field_val = transformed_source_map[field_alias]
|
|
364
|
+
# Clean the mapping values
|
|
365
|
+
if (fix_source_val) and ('-' in field_val or ':' in field_val):
|
|
366
|
+
field_val = field_val.replace('-', ':').split(':')[0]
|
|
367
|
+
source_values.append(field_val)
|
|
368
|
+
|
|
369
|
+
# Only create mapping if we have values for all source fields
|
|
370
|
+
if len(source_values) == len(source_field_aliases):
|
|
371
|
+
combined_source_val = '|'.join(source_values)
|
|
372
|
+
|
|
373
|
+
# Clean target value
|
|
374
|
+
if (fix_target_val) and ('-' in target_val or ':' in target_val):
|
|
375
|
+
target_val = target_val.replace('-', ':').split(':')[0]
|
|
376
|
+
|
|
377
|
+
replacements[combined_source_val] = target_val
|
|
378
|
+
|
|
379
|
+
# Apply the mappings if any were found
|
|
380
|
+
if replacements:
|
|
381
|
+
# Determine if we modify the column in-place or create a new one
|
|
382
|
+
if len(mappings) == 1:
|
|
383
|
+
target_field = source_column_for_mapping
|
|
384
|
+
else:
|
|
385
|
+
# Extract the target field name from the mapping configuration
|
|
386
|
+
target_field = list(mapping.values[0].output)[0].split('-')[1]
|
|
387
|
+
|
|
388
|
+
# Retrieve the default value from the mapping configuration
|
|
389
|
+
default_val = mapping.default_value
|
|
390
|
+
|
|
391
|
+
# Apply mapping: use the replacement if key exists, otherwise use the default value.
|
|
392
|
+
# If no default_val is provided, it falls back to the original value (x).
|
|
393
|
+
df[target_field] = df[source_column_for_mapping].apply(
|
|
394
|
+
lambda x: replacements.get(x, default_val if default_val else x)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Dropping unmapped values only makes sense if a default value is NOT being used
|
|
398
|
+
if drop_unmapped and not default_val:
|
|
399
|
+
# Keep only the rows where the original value was part of the explicit mapping
|
|
400
|
+
df = df[df[source_column_for_mapping].isin(replacements.keys())]
|
|
401
|
+
|
|
402
|
+
return df, handled_source_fields
|
|
403
|
+
|
|
404
|
+
def rename_fields(self, df: pd.DataFrame, scenario_name: str = None, field_mapping: Dict[str, Union[str, List[str]]] = None,
|
|
405
|
+
columns_to_keep: List[str] = None, drop_unmapped: bool = True) -> pd.DataFrame:
|
|
406
|
+
"""
|
|
407
|
+
Rename fields with clear, separate logic for unique and non-unique mappings.
|
|
408
|
+
"""
|
|
409
|
+
if columns_to_keep is None:
|
|
410
|
+
columns_to_keep = []
|
|
411
|
+
|
|
412
|
+
if scenario_name and not field_mapping:
|
|
413
|
+
parsed_scenario = self[scenario_name]
|
|
414
|
+
use_pythonic = self.source_system and self.source_sdk_config
|
|
415
|
+
field_mapping = parsed_scenario.source_pythonic_to_target if use_pythonic else parsed_scenario.source_to_target_map
|
|
416
|
+
elif not field_mapping:
|
|
417
|
+
raise ValueError("Either scenario_name or field_mapping must be provided")
|
|
418
|
+
|
|
419
|
+
# Build a definitive plan for all renaming tasks
|
|
420
|
+
# This will store the final plan, e.g., {'source_A': ['target_1', 'target_2']}
|
|
421
|
+
planned_tasks = defaultdict(list)
|
|
422
|
+
# This set will help us identify which targets are part of a conflict.
|
|
423
|
+
conflicting_targets = {
|
|
424
|
+
target for target, sources in parsed_scenario.target_to_source_map.items() if len(sources) > 1
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
# Plan the renaming for all NON-UNIQUE (conflicting)
|
|
428
|
+
# NOTE: commented out since there is now logic below to handle conflicting targets (when there are multiple sources for a target)
|
|
429
|
+
# for target in conflicting_targets:
|
|
430
|
+
# source_list = parsed_scenario.target_to_source_map.get(target, [])
|
|
431
|
+
# conflicting_pythonic_sources = sorted([
|
|
432
|
+
# parsed_scenario.alias_to_pythonic.get(s, s) for s in source_list
|
|
433
|
+
# ])
|
|
434
|
+
# for i, pythonic_source in enumerate(conflicting_pythonic_sources):
|
|
435
|
+
# new_name = f"{target}_{i + 1}"
|
|
436
|
+
# planned_tasks[pythonic_source].append(new_name)
|
|
437
|
+
|
|
438
|
+
# Plan the renaming for all UNIQUE sources
|
|
439
|
+
for source, target in field_mapping.items():
|
|
440
|
+
individual_targets = []
|
|
441
|
+
if isinstance(target, str):
|
|
442
|
+
individual_targets = [target]
|
|
443
|
+
elif isinstance(target, list):
|
|
444
|
+
individual_targets = target
|
|
445
|
+
for target_field in individual_targets:
|
|
446
|
+
if target_field not in conflicting_targets:
|
|
447
|
+
# If the target is not in our conflict list, it's a simple, unique mapping.
|
|
448
|
+
planned_tasks[source].append(target_field)
|
|
449
|
+
|
|
450
|
+
for target in conflicting_targets:
|
|
451
|
+
source_list = parsed_scenario.target_to_source_map.get(target, [])
|
|
452
|
+
source_list = sorted(source_list)
|
|
453
|
+
pythonic_source_list = [parsed_scenario.alias_to_pythonic.get(source, source) for source in source_list]
|
|
454
|
+
source_list = '|'.join(pythonic_source_list)
|
|
455
|
+
planned_tasks[source_list].append(target)
|
|
456
|
+
|
|
457
|
+
# Execute the plan on the DataFrame
|
|
458
|
+
newly_created_target_fields = set()
|
|
459
|
+
for source, final_targets in planned_tasks.items():
|
|
460
|
+
if source in df.columns:
|
|
461
|
+
for final_target_name in final_targets:
|
|
462
|
+
df[final_target_name] = df[source]
|
|
463
|
+
newly_created_target_fields.add(final_target_name)
|
|
464
|
+
else:
|
|
465
|
+
for final_target_name in final_targets:
|
|
466
|
+
df[final_target_name] = ''
|
|
467
|
+
newly_created_target_fields.add(final_target_name)
|
|
468
|
+
|
|
469
|
+
# Cleanup and Finalizing Section
|
|
470
|
+
if drop_unmapped:
|
|
471
|
+
protected_columns = {'employee_id', 'person_id', 'id'} | newly_created_target_fields | set(columns_to_keep)
|
|
472
|
+
columns_to_drop = [col for col in planned_tasks.keys() if col not in protected_columns]
|
|
473
|
+
df = df.drop(columns=columns_to_drop, errors='ignore')
|
|
474
|
+
all_expected_columns = list(protected_columns) + columns_to_keep
|
|
475
|
+
final_df_columns = [col for col in df.columns if col in all_expected_columns]
|
|
476
|
+
df = df[final_df_columns].copy()
|
|
477
|
+
columns_missing_in_df = [col for col in all_expected_columns if col not in df.columns]
|
|
478
|
+
if columns_missing_in_df:
|
|
479
|
+
for col in columns_missing_in_df:
|
|
480
|
+
df[col] = None
|
|
481
|
+
|
|
482
|
+
return df
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from .credentials import CredentialsConfig, CredentialSource, CredentialData
|
|
2
2
|
from .customers import CustomerSchema, CustomerUsers, CustomerContractDetailsSchema
|
|
3
|
+
from .scenarios import Scenario, ScenarioMappingConfiguration, ScenarioDetail, SourceTarget
|
|
3
4
|
from .interfaces import Interface, InterfaceApps, InterfaceDetail, InterfaceConfig, Schedule, Scope, DevSettings, Frequency, TaskSchedule, MappingValue, MappingItem
|
|
4
5
|
from .organization_chart import OrganizationChartNode, OrganizationLayerCreate, OrganizationLayerUpdate, OrganizationLayerGet, OrganizationNode, OrganizationNodeCreate, OrganizationNodeUpdate
|
|
5
6
|
from .roles import DashboardRight, QlikDashboardRight, CreateRoleRequest, RoleUser, RoleSchema
|
|
@@ -11,7 +12,7 @@ __all__ = [
|
|
|
11
12
|
"CredentialSource",
|
|
12
13
|
"CredentialData",
|
|
13
14
|
"CustomerSchema",
|
|
14
|
-
"CustomerUsers",
|
|
15
|
+
"CustomerUsers",
|
|
15
16
|
"CustomerContractDetailsSchema",
|
|
16
17
|
"Interface",
|
|
17
18
|
"InterfaceApps",
|
|
@@ -48,4 +49,5 @@ __all__ = [
|
|
|
48
49
|
"User",
|
|
49
50
|
"QlikAppUserAuthorization",
|
|
50
51
|
"MappingValue",
|
|
51
|
-
|
|
52
|
+
"Scenario",
|
|
53
|
+
]
|
|
@@ -179,55 +179,3 @@ class DevSettings(BaseModel):
|
|
|
179
179
|
frozen = True
|
|
180
180
|
strict = True
|
|
181
181
|
populate_by_name = True
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
class SourceTarget(BaseModel):
|
|
185
|
-
"""Schema for source/target configuration in scenario details"""
|
|
186
|
-
data: List[str] = Field(..., description="List of data field names")
|
|
187
|
-
type: str = Field(..., description="Type of the source/target (e.g., 'LIBRARY')")
|
|
188
|
-
|
|
189
|
-
class Config:
|
|
190
|
-
frozen = True
|
|
191
|
-
strict = True
|
|
192
|
-
populate_by_name = True
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
class ScenarioMappingConfiguration(BaseModel):
|
|
196
|
-
"""Schema for mapping configuration in scenario details"""
|
|
197
|
-
values: List[str] = Field(default_factory=list, description="List of mapping values")
|
|
198
|
-
default_value: str = Field(default="", alias="defaultValue", description="Default value for mapping")
|
|
199
|
-
|
|
200
|
-
class Config:
|
|
201
|
-
frozen = True
|
|
202
|
-
strict = True
|
|
203
|
-
populate_by_name = True
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
class ScenarioDetail(BaseModel):
|
|
207
|
-
"""Schema for scenario detail mapping"""
|
|
208
|
-
id: str = Field(..., description="Detail ID")
|
|
209
|
-
logic: str = Field(default="", description="Mapping logic")
|
|
210
|
-
unique: bool = Field(..., description="Whether the mapping is unique")
|
|
211
|
-
required: bool = Field(..., description="Whether the field is required")
|
|
212
|
-
mapping_required: bool = Field(..., alias="mappingRequired", description="Whether the mapping is required")
|
|
213
|
-
source: SourceTarget = Field(..., description="Source configuration")
|
|
214
|
-
target: SourceTarget = Field(..., description="Target configuration")
|
|
215
|
-
mapping: ScenarioMappingConfiguration = Field(..., description="Mapping configuration")
|
|
216
|
-
|
|
217
|
-
class Config:
|
|
218
|
-
frozen = True
|
|
219
|
-
strict = True
|
|
220
|
-
populate_by_name = True
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
class Scenario(BaseModel):
|
|
224
|
-
"""Schema for interface scenario configuration"""
|
|
225
|
-
id: str = Field(..., description="Scenario ID")
|
|
226
|
-
name: str = Field(..., description="Scenario name")
|
|
227
|
-
description: str = Field(default="", description="Scenario description")
|
|
228
|
-
details: List[ScenarioDetail] = Field(..., description="Scenario mapping details")
|
|
229
|
-
|
|
230
|
-
class Config:
|
|
231
|
-
frozen = True
|
|
232
|
-
strict = True
|
|
233
|
-
populate_by_name = True
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Type, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
from typing_extensions import Annotated
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# data level models
|
|
10
|
+
class MappingValue(BaseModel):
|
|
11
|
+
"""Represents a single input-to-output mapping rule."""
|
|
12
|
+
input: Dict[str, str]
|
|
13
|
+
output: Dict[str, str]
|
|
14
|
+
|
|
15
|
+
class Config:
|
|
16
|
+
frozen = True
|
|
17
|
+
strict = True
|
|
18
|
+
populate_by_name = True
|
|
19
|
+
|
|
20
|
+
class CustomData(BaseModel):
|
|
21
|
+
uuid: str = Field(..., description="Stable identifier for the custom field")
|
|
22
|
+
name: str = Field(..., description="Human-readable field name")
|
|
23
|
+
technical_name: str = Field(
|
|
24
|
+
...,
|
|
25
|
+
alias="technicalName",
|
|
26
|
+
description="Canonical identifier used in the source system"
|
|
27
|
+
)
|
|
28
|
+
source: str = Field(..., description="Source category bucket")
|
|
29
|
+
description: str = Field(..., description="Business description / purpose")
|
|
30
|
+
|
|
31
|
+
class Config:
|
|
32
|
+
frozen = True
|
|
33
|
+
strict = True
|
|
34
|
+
populate_by_name = True
|
|
35
|
+
|
|
36
|
+
# Field scale models (Source/Target branches models)
|
|
37
|
+
class CustomSourceTarget(BaseModel):
|
|
38
|
+
type: Literal["CUSTOM"] = Field(
|
|
39
|
+
"CUSTOM",
|
|
40
|
+
description="Discriminator—always 'CUSTOM' for this branch"
|
|
41
|
+
)
|
|
42
|
+
data: List[CustomData] = Field(
|
|
43
|
+
...,
|
|
44
|
+
description="List of rich field descriptors coming from an external system"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
class Config:
|
|
48
|
+
frozen = True
|
|
49
|
+
strict = True
|
|
50
|
+
populate_by_name = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LibrarySourceTarget(BaseModel):
|
|
54
|
+
type: Literal["LIBRARY"] = Field(
|
|
55
|
+
"LIBRARY",
|
|
56
|
+
description="Discriminator—fixed value for library look-ups"
|
|
57
|
+
)
|
|
58
|
+
data: List[str] = Field(
|
|
59
|
+
...,
|
|
60
|
+
description="List of library field identifiers"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
class Config:
|
|
64
|
+
frozen = True
|
|
65
|
+
strict = True
|
|
66
|
+
populate_by_name = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FixedSourceTarget(BaseModel):
|
|
70
|
+
type: Literal["FIXED"] = Field(
|
|
71
|
+
"FIXED",
|
|
72
|
+
description="Discriminator—fixed value for constant/literal values"
|
|
73
|
+
)
|
|
74
|
+
data: str = Field(
|
|
75
|
+
...,
|
|
76
|
+
description="A fixed literal value (e.g., '082')"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
class Config:
|
|
80
|
+
frozen = True
|
|
81
|
+
strict = True
|
|
82
|
+
populate_by_name = True
|
|
83
|
+
|
|
84
|
+
SourceTarget = Annotated[
|
|
85
|
+
Union[CustomSourceTarget, LibrarySourceTarget, FixedSourceTarget],
|
|
86
|
+
Field(discriminator="type", description="Polymorphic source/target contract"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# Field scale models (Field properties)
|
|
90
|
+
class FieldProperties(BaseModel):
|
|
91
|
+
"""Metadata for a single field‑mapping detail returned by the API."""
|
|
92
|
+
model_config = ConfigDict(extra="allow", frozen=True)
|
|
93
|
+
logic: Optional[str] = None
|
|
94
|
+
unique: bool = False
|
|
95
|
+
required: bool = False
|
|
96
|
+
mapping: Dict[str, Any] = Field(default_factory=dict)
|
|
97
|
+
system_type: Optional[str] = None # "source" or "target"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Down-stream models (from scenario to field, nested in scenario detail)
|
|
101
|
+
class ScenarioMappingConfiguration(BaseModel):
|
|
102
|
+
# The type hint for 'values' is updated to use the new MappingValue model
|
|
103
|
+
values: List[MappingValue] = Field(
|
|
104
|
+
default_factory=list,
|
|
105
|
+
description="Explicit mapping values when value mapping is required"
|
|
106
|
+
)
|
|
107
|
+
default_value: str = Field(
|
|
108
|
+
default="",
|
|
109
|
+
alias="defaultValue",
|
|
110
|
+
description="Fallback value applied when no mapping match is found"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
class Config:
|
|
114
|
+
frozen = True
|
|
115
|
+
strict = True
|
|
116
|
+
populate_by_name = True
|
|
117
|
+
|
|
118
|
+
class ScenarioDetail(BaseModel):
|
|
119
|
+
id: str = Field(..., description="Primary key of the detail record")
|
|
120
|
+
logic: str = Field(default="", description="Optional transformation logic")
|
|
121
|
+
unique: Optional[bool] = Field(default=False, description="Must this mapping be unique across the scenario?")
|
|
122
|
+
required: Optional[bool] = Field(default=False, description="Is the field mandatory?")
|
|
123
|
+
mapping_required: Optional[bool] = Field(
|
|
124
|
+
default=False,
|
|
125
|
+
alias="mappingRequired",
|
|
126
|
+
description="Flag indicating whether an explicit mapping table is needed, right now not always present in reponse so defaults to False."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
source: SourceTarget = Field(..., description="Source definition")
|
|
130
|
+
target: SourceTarget = Field(..., description="Target definition")
|
|
131
|
+
mapping: ScenarioMappingConfiguration = Field(
|
|
132
|
+
..., description="Mapping/value-translation configuration"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
class Config:
|
|
136
|
+
frozen = True
|
|
137
|
+
strict = True
|
|
138
|
+
populate_by_name = True
|
|
139
|
+
|
|
140
|
+
# Scenario models
|
|
141
|
+
class Scenario(BaseModel):
|
|
142
|
+
id: str = Field(..., description="Scenario identifier")
|
|
143
|
+
name: str = Field(..., description="Scenario display name")
|
|
144
|
+
description: str = Field(default="", description="Scenario business context")
|
|
145
|
+
details: List[ScenarioDetail] = Field(
|
|
146
|
+
..., description="Collection of field-level mappings"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
class Config:
|
|
150
|
+
frozen = True
|
|
151
|
+
strict = True
|
|
152
|
+
populate_by_name = True
|
|
153
|
+
|
|
154
|
+
class ParsedScenario(BaseModel):
|
|
155
|
+
"""
|
|
156
|
+
Create object that contains all the information about a scenario that is returned by the API.
|
|
157
|
+
This object is used to access the scenario data in a pythonic and flexible way.
|
|
158
|
+
"""
|
|
159
|
+
# Core attributes
|
|
160
|
+
name: str
|
|
161
|
+
id: str
|
|
162
|
+
details_count: int
|
|
163
|
+
|
|
164
|
+
# Derived mappings
|
|
165
|
+
source_to_target_map: Dict[str, List[str]]
|
|
166
|
+
target_to_source_map: Dict[str, List[str]]
|
|
167
|
+
field_properties: Dict[str, FieldProperties]
|
|
168
|
+
all_source_fields: Set[str]
|
|
169
|
+
all_target_fields: Set[str]
|
|
170
|
+
unique_fields: List[str]
|
|
171
|
+
required_fields: List[str]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
alias_to_pythonic: Optional[Dict[str, str]] = None
|
|
175
|
+
pythonic_to_alias: Optional[Dict[str, str]] = None
|
|
176
|
+
all_pythonic_source_fields: Optional[List[str]] = None
|
|
177
|
+
source_pythonic_to_target: Optional[Dict[str, List[str]]] = None
|
|
178
|
+
target_to_source_pythonic: Optional[Dict[str, Union[str, List[str]]]] = None
|
|
179
|
+
|
|
180
|
+
# Direct lookup for value mappings from a source field
|
|
181
|
+
source_to_value_mappings: Dict[str, List[ScenarioMappingConfiguration]]
|
|
182
|
+
|
|
183
|
+
#public methods with specific functionality
|
|
184
|
+
def get_mapped_field_names(self, field_name: str, direction: str = "source_to_target") -> List[str]:
|
|
185
|
+
"""
|
|
186
|
+
Return all mapped fields for `field_name` based on the mapping `direction`.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
field_name: The name of the field to look up.
|
|
190
|
+
direction: Can be "source_to_target" (default) or "target_to_source".
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A list of mapped field names.
|
|
194
|
+
"""
|
|
195
|
+
if direction == "source_to_target":
|
|
196
|
+
return self.source_to_target_map.get(field_name, [])
|
|
197
|
+
if direction == "target_to_source":
|
|
198
|
+
return self.target_to_source_map.get(field_name, [])
|
|
199
|
+
raise ValueError("Direction must be 'source_to_target' or 'target_to_source'.")
|
|
200
|
+
|
|
201
|
+
def get_value_mappings(self, source_field_name: str) -> List[ScenarioMappingConfiguration]:
|
|
202
|
+
"""
|
|
203
|
+
Return all value mapping configurations for a given source field.
|
|
204
|
+
"""
|
|
205
|
+
return self.source_to_value_mappings.get(source_field_name, [])
|
|
206
|
+
|
|
207
|
+
def get_source_fields_with_value_mappings(self) -> List[str]:
|
|
208
|
+
"""Returns a list of source fields that have value mappings."""
|
|
209
|
+
return list(self.source_to_value_mappings.keys())
|
|
210
|
+
|
|
211
|
+
def has_field(self, field_name: str, field_type: Optional[str] = None) -> bool:
|
|
212
|
+
"""Check field existence in scenario. Can denote source or target, else looks for both."""
|
|
213
|
+
if field_type == "source":
|
|
214
|
+
return field_name in self.all_source_fields
|
|
215
|
+
if field_type == "target":
|
|
216
|
+
return field_name in self.all_target_fields
|
|
217
|
+
return field_name in self.all_source_fields or field_name in self.all_target_fields
|
|
218
|
+
|
|
219
|
+
#Dunder methods for pythonic field access
|
|
220
|
+
def __getitem__(self, field_id: str) -> FieldProperties:
|
|
221
|
+
"""Enable dict-style access to field properties: `scenario['customer_id']`."""
|
|
222
|
+
try:
|
|
223
|
+
return self.field_properties[field_id]
|
|
224
|
+
except KeyError as exc:
|
|
225
|
+
raise KeyError(f"Field '{field_id}' not found in scenario '{self.name}'.") from exc
|
|
226
|
+
|
|
227
|
+
def __getattr__(self, name: str) -> FieldProperties:
|
|
228
|
+
"""Enable attribute-style access to field properties: `scenario.customer_id.unique`."""
|
|
229
|
+
if name.startswith("_") or name in self.__dict__ or name in self.__class__.__dict__:
|
|
230
|
+
return super().__getattribute__(name)
|
|
231
|
+
try:
|
|
232
|
+
return self.field_properties[name]
|
|
233
|
+
except KeyError as exc:
|
|
234
|
+
raise AttributeError(f"'{name}' is not a valid field in scenario '{self.name}'.") from exc
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
"""A human-friendly string representation."""
|
|
238
|
+
return (
|
|
239
|
+
f"<ParsedScenario name='{self.name}' id='{self.id}' "
|
|
240
|
+
f"details={self.details_count} unique={len(self.unique_fields)} required={len(self.required_fields)}>"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_api_dict(cls, scenario: Dict[str, Any], source_sdk: Any, sdk_mapping_config: Any, temp_scenario_column_fix: Dict[str, str]=None, source_field_remove_prefix: Optional[Dict[str, str]]=None) -> "ParsedScenario":
|
|
245
|
+
"""
|
|
246
|
+
Factory method to transform raw API scenario data into a ParsedScenario object.
|
|
247
|
+
|
|
248
|
+
This method processes the raw scenario dictionary from the API and:
|
|
249
|
+
- Extracts field mappings from scenario details
|
|
250
|
+
- Builds bidirectional source-to-target and target-to-source mapping dictionaries
|
|
251
|
+
- Creates field properties for each field with metadata (unique, required, logic, etc.)
|
|
252
|
+
- Identifies all source and target fields
|
|
253
|
+
- Categorizes fields by their properties (unique, required)
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
scenario (Dict[str, Any]): Raw scenario dictionary from the BrynQ API containing
|
|
257
|
+
'name', 'id', 'details' and other scenario metadata.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
ParsedScenario: A fully parsed scenario object with convenient access methods
|
|
261
|
+
for field mappings, properties, and validation capabilities.
|
|
262
|
+
"""
|
|
263
|
+
details = scenario.get("details", [])
|
|
264
|
+
src_map: Dict[str, Set[str]] = defaultdict(set)
|
|
265
|
+
tgt_map: Dict[str, Set[str]] = defaultdict(set)
|
|
266
|
+
props: Dict[str, FieldProperties] = {}
|
|
267
|
+
source_to_value_maps: Dict[str, List[ScenarioMappingConfiguration]] = defaultdict(list)
|
|
268
|
+
|
|
269
|
+
def _extract_name(path: dict, data_type: str = "LIBRARY") -> Optional[str]:
|
|
270
|
+
"""Extracts a name from a path object based on its data type."""
|
|
271
|
+
# Process dictionary-based paths.
|
|
272
|
+
if isinstance(path, dict):
|
|
273
|
+
if data_type == "CUSTOM":
|
|
274
|
+
return path.get("technical_name")
|
|
275
|
+
if data_type == "LIBRARY":
|
|
276
|
+
return path.get("data")
|
|
277
|
+
else:
|
|
278
|
+
return None
|
|
279
|
+
# For data_type == "FIXED" or any other unhandled type, implicitly return None.
|
|
280
|
+
elif isinstance(path, str): #string-based paths
|
|
281
|
+
if data_type != "FIXED":
|
|
282
|
+
return path.split("-")[-1]
|
|
283
|
+
else:
|
|
284
|
+
return None
|
|
285
|
+
else:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
for detail in details:
|
|
289
|
+
source_data = detail.get("source", {}).get("data", [])
|
|
290
|
+
source_type = detail.get("source", {}).get("type", "LIBRARY")
|
|
291
|
+
target_data = detail.get("target", {}).get("data", [])
|
|
292
|
+
target_type = detail.get("target", {}).get("type", "LIBRARY")
|
|
293
|
+
|
|
294
|
+
source_names = {name for p in source_data for name in [ _extract_name(p, source_type) ] if name is not None}
|
|
295
|
+
target_names = {name for p in target_data for name in [ _extract_name(p, target_type) ] if name is not None}
|
|
296
|
+
|
|
297
|
+
for s_name in source_names:
|
|
298
|
+
src_map[s_name].update(target_names)
|
|
299
|
+
for t_name in target_names:
|
|
300
|
+
tgt_map[t_name].update(source_names)
|
|
301
|
+
|
|
302
|
+
mapping_config_data = detail.get("mapping")
|
|
303
|
+
if mapping_config_data:
|
|
304
|
+
mapping_config = ScenarioMappingConfiguration.model_validate(mapping_config_data)
|
|
305
|
+
if mapping_config.values:
|
|
306
|
+
key = '|'.join(source_names)
|
|
307
|
+
source_to_value_maps[key].append(mapping_config)
|
|
308
|
+
|
|
309
|
+
base_props = FieldProperties.model_validate(detail)
|
|
310
|
+
|
|
311
|
+
# Create source field properties
|
|
312
|
+
for field_name in source_names:
|
|
313
|
+
source_props = base_props.model_copy(update={"system_type": "source"})
|
|
314
|
+
props[field_name] = source_props
|
|
315
|
+
|
|
316
|
+
# Create target field properties
|
|
317
|
+
for field_name in target_names:
|
|
318
|
+
target_props = base_props.model_copy(update={"system_type": "target"})
|
|
319
|
+
props[field_name] = target_props
|
|
320
|
+
|
|
321
|
+
all_source_fields = set(src_map.keys())
|
|
322
|
+
unique_fields = [fid for fid, props in props.items() if props.unique]
|
|
323
|
+
required_fields = [fid for fid, props in props.items() if props.required]
|
|
324
|
+
source_to_target_map = {k: sorted(v) for k, v in src_map.items()}
|
|
325
|
+
target_to_source_map = {k: sorted(v) for k, v in tgt_map.items()}
|
|
326
|
+
all_target_fields = set(tgt_map.keys())
|
|
327
|
+
|
|
328
|
+
#_--- 2. Conditionally generate the alias mappings ---
|
|
329
|
+
alias_to_pythonic = None
|
|
330
|
+
source_pythonic_to_target = None
|
|
331
|
+
target_to_source_pythonic = None
|
|
332
|
+
all_pythonic_source_fields = None
|
|
333
|
+
|
|
334
|
+
if source_sdk and sdk_mapping_config:
|
|
335
|
+
alias_to_pythonic = cls._generate_sdk_alias_mappings(
|
|
336
|
+
scenario_name=scenario.get("name", "Unnamed"),
|
|
337
|
+
source_fields=all_source_fields,
|
|
338
|
+
source_sdk=source_sdk,
|
|
339
|
+
sdk_mapping_config=sdk_mapping_config,
|
|
340
|
+
temp_scenario_column_fix=temp_scenario_column_fix,
|
|
341
|
+
source_field_remove_prefix=source_field_remove_prefix
|
|
342
|
+
)
|
|
343
|
+
source_pythonic_to_target = {}
|
|
344
|
+
for k, v in source_to_target_map.items():
|
|
345
|
+
pythonic_name = alias_to_pythonic.get(k)
|
|
346
|
+
if pythonic_name:
|
|
347
|
+
source_pythonic_to_target[pythonic_name] = v
|
|
348
|
+
|
|
349
|
+
# Add custom fields that have target mappings but no pythonic mapping
|
|
350
|
+
for source_alias, target_fields in source_to_target_map.items():
|
|
351
|
+
if source_alias not in alias_to_pythonic and target_fields: # non-empty list of targets
|
|
352
|
+
# Use the source alias as the key (since there's no pythonic name)
|
|
353
|
+
source_pythonic_to_target[source_alias] = target_fields
|
|
354
|
+
|
|
355
|
+
#add reverse mapping - handle lists by creating multiple entries
|
|
356
|
+
target_to_source_pythonic = {}
|
|
357
|
+
for source_key, target_list in source_pythonic_to_target.items():
|
|
358
|
+
if isinstance(target_list, list):
|
|
359
|
+
for target in target_list:
|
|
360
|
+
target_to_source_pythonic[target] = source_key
|
|
361
|
+
else:
|
|
362
|
+
target_to_source_pythonic[target_list] = source_key
|
|
363
|
+
|
|
364
|
+
all_pythonic_source_fields = list(set(source_pythonic_to_target.keys())) if source_pythonic_to_target else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --- 3. Construct the final, frozen instance in a single call ---
|
|
368
|
+
instance = cls(
|
|
369
|
+
name=scenario.get("name", "Unnamed"),
|
|
370
|
+
id=scenario.get("id", ""),
|
|
371
|
+
details_count=len(details),
|
|
372
|
+
source_to_target_map=source_to_target_map,
|
|
373
|
+
target_to_source_map=target_to_source_map,
|
|
374
|
+
field_properties=props,
|
|
375
|
+
unique_fields=unique_fields,
|
|
376
|
+
required_fields=required_fields,
|
|
377
|
+
all_source_fields=all_source_fields,
|
|
378
|
+
all_pythonic_source_fields=all_pythonic_source_fields,
|
|
379
|
+
all_target_fields=all_target_fields,
|
|
380
|
+
source_to_value_mappings=dict(source_to_value_maps),
|
|
381
|
+
alias_to_pythonic=alias_to_pythonic,
|
|
382
|
+
source_pythonic_to_target=source_pythonic_to_target,
|
|
383
|
+
target_to_source_pythonic=target_to_source_pythonic,
|
|
384
|
+
pythonic_to_alias={v: k for k, v in alias_to_pythonic.items()} if alias_to_pythonic else None
|
|
385
|
+
)
|
|
386
|
+
return instance
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _generate_sdk_alias_mappings(
|
|
390
|
+
scenario_name: str,
|
|
391
|
+
source_fields: Set[str],
|
|
392
|
+
source_sdk: Any,
|
|
393
|
+
sdk_mapping_config: Dict,
|
|
394
|
+
temp_scenario_column_fix: Optional[Dict] = None,
|
|
395
|
+
source_field_remove_prefix: Optional[Dict[str, str]] = None
|
|
396
|
+
) -> Tuple[List[str], Dict[str, str]]:
|
|
397
|
+
"""
|
|
398
|
+
Performs a strict validation of source fields against source SDK schemas.
|
|
399
|
+
This static method is a self-contained helper for the factory.
|
|
400
|
+
"""
|
|
401
|
+
fixes = (temp_scenario_column_fix or {}).get(scenario_name, {})
|
|
402
|
+
fields_to_check = [fixes.get(f, f) for f in source_fields]
|
|
403
|
+
|
|
404
|
+
# get schema classes and extract pythonic mappings
|
|
405
|
+
source_schema_fields = []
|
|
406
|
+
source_alias_to_pythonic = {}
|
|
407
|
+
|
|
408
|
+
mapping = sdk_mapping_config.get(scenario_name)
|
|
409
|
+
if mapping is None:
|
|
410
|
+
raise ValueError(f"No SDK mapping found for scenario '{scenario_name}'")
|
|
411
|
+
if isinstance(mapping, str):
|
|
412
|
+
schema_classes = [mapping]
|
|
413
|
+
elif isinstance(mapping, list):
|
|
414
|
+
schema_classes = mapping
|
|
415
|
+
elif isinstance(mapping, dict) and 'tables' in mapping:
|
|
416
|
+
schema_classes = mapping['tables']
|
|
417
|
+
else:
|
|
418
|
+
raise ValueError(f"Invalid SDK mapping format for scenario '{scenario_name}': {mapping}")
|
|
419
|
+
|
|
420
|
+
for schema_class_name in schema_classes:
|
|
421
|
+
sdk_attr_name = schema_class_name.replace('Schema', '').lower() # e.g. 'people' from PeopleSchema
|
|
422
|
+
clss = getattr(source_sdk, sdk_attr_name)
|
|
423
|
+
schema_clss = clss.schema
|
|
424
|
+
schema_vars = vars(schema_clss)
|
|
425
|
+
#Loop over the schema class attributes
|
|
426
|
+
for pythonic_field_name, field_info in schema_vars.items():
|
|
427
|
+
if hasattr(field_info, '__class__') and 'FieldInfo' in field_info.__class__.__name__:
|
|
428
|
+
# Extract alias from FieldInfo object
|
|
429
|
+
alias = str(field_info).split('"')[1] # Get the string between quotes
|
|
430
|
+
source_schema_fields.append(alias)
|
|
431
|
+
source_alias_to_pythonic[alias] = pythonic_field_name
|
|
432
|
+
|
|
433
|
+
return source_alias_to_pythonic
|
|
434
|
+
class Config:
|
|
435
|
+
frozen = True
|
|
436
|
+
strict = True
|
|
437
|
+
populate_by_name = True
|
|
@@ -7,6 +7,7 @@ brynq_sdk_brynq/interfaces.py
|
|
|
7
7
|
brynq_sdk_brynq/mappings.py
|
|
8
8
|
brynq_sdk_brynq/organization_chart.py
|
|
9
9
|
brynq_sdk_brynq/roles.py
|
|
10
|
+
brynq_sdk_brynq/scenarios.py
|
|
10
11
|
brynq_sdk_brynq/source_systems.py
|
|
11
12
|
brynq_sdk_brynq/users.py
|
|
12
13
|
brynq_sdk_brynq.egg-info/PKG-INFO
|
|
@@ -21,4 +22,5 @@ brynq_sdk_brynq/schemas/customers.py
|
|
|
21
22
|
brynq_sdk_brynq/schemas/interfaces.py
|
|
22
23
|
brynq_sdk_brynq/schemas/organization_chart.py
|
|
23
24
|
brynq_sdk_brynq/schemas/roles.py
|
|
25
|
+
brynq_sdk_brynq/schemas/scenarios.py
|
|
24
26
|
brynq_sdk_brynq/schemas/users.py
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
from setuptools import
|
|
1
|
+
from setuptools import find_namespace_packages, setup
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name='brynq_sdk_brynq',
|
|
5
|
-
version='4.0.
|
|
5
|
+
version='4.0.8',
|
|
6
6
|
description='BrynQ SDK for the BrynQ.com platform',
|
|
7
7
|
long_description='BrynQ SDK for the BrynQ.com platform',
|
|
8
8
|
author='BrynQ',
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/organization_chart.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|