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.

Files changed (28) hide show
  1. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/PKG-INFO +1 -1
  2. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/interfaces.py +3 -47
  3. brynq_sdk_brynq-4.0.8/brynq_sdk_brynq/scenarios.py +482 -0
  4. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/__init__.py +4 -2
  5. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/interfaces.py +0 -52
  6. brynq_sdk_brynq-4.0.8/brynq_sdk_brynq/schemas/scenarios.py +437 -0
  7. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/PKG-INFO +1 -1
  8. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/SOURCES.txt +2 -0
  9. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/setup.py +2 -2
  10. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/__init__.py +0 -0
  11. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/brynq.py +0 -0
  12. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/credentials.py +0 -0
  13. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/customers.py +0 -0
  14. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/mappings.py +0 -0
  15. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/organization_chart.py +0 -0
  16. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/roles.py +0 -0
  17. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/credentials.py +0 -0
  18. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/customers.py +0 -0
  19. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/organization_chart.py +0 -0
  20. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/roles.py +0 -0
  21. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/schemas/users.py +0 -0
  22. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/source_systems.py +0 -0
  23. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq/users.py +0 -0
  24. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/dependency_links.txt +0 -0
  25. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/not-zip-safe +0 -0
  26. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/requires.txt +0 -0
  27. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/brynq_sdk_brynq.egg-info/top_level.txt +0 -0
  28. {brynq_sdk_brynq-4.0.4 → brynq_sdk_brynq-4.0.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.0
2
2
  Name: brynq_sdk_brynq
3
- Version: 4.0.4
3
+ Version: 4.0.8
4
4
  Summary: BrynQ SDK for the BrynQ.com platform
5
5
  Home-page: UNKNOWN
6
6
  Author: BrynQ
@@ -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 .schemas.interfaces import Interface, InterfaceDetail, InterfaceConfig, Schedule, Scope, DevSettings, Scenario
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 1.0
2
2
  Name: brynq-sdk-brynq
3
- Version: 4.0.4
3
+ Version: 4.0.8
4
4
  Summary: BrynQ SDK for the BrynQ.com platform
5
5
  Home-page: UNKNOWN
6
6
  Author: BrynQ
@@ -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 setup, find_namespace_packages
1
+ from setuptools import find_namespace_packages, setup
2
2
 
3
3
  setup(
4
4
  name='brynq_sdk_brynq',
5
- version='4.0.4',
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',