pyxecm 3.4.7__tar.gz → 3.4.9__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.
Files changed (72) hide show
  1. {pyxecm-3.4.7 → pyxecm-3.4.9}/PKG-INFO +1 -1
  2. {pyxecm-3.4.7 → pyxecm-3.4.9}/pyproject.toml +1 -1
  3. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otcs.py +219 -26
  4. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otds.py +94 -0
  5. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/payload.py +3 -1
  6. {pyxecm-3.4.7 → pyxecm-3.4.9}/.gitignore +0 -0
  7. {pyxecm-3.4.7 → pyxecm-3.4.9}/README.md +0 -0
  8. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/__init__.py +0 -0
  9. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/avts.py +0 -0
  10. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/coreshare.py +0 -0
  11. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/helper/__init__.py +0 -0
  12. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/helper/assoc.py +0 -0
  13. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/helper/data.py +0 -0
  14. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/helper/logadapter.py +0 -0
  15. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/helper/web.py +0 -0
  16. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/helper/xml.py +0 -0
  17. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otac.py +0 -0
  18. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otawp.py +0 -0
  19. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otca.py +0 -0
  20. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otiv.py +0 -0
  21. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otkd.py +0 -0
  22. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otmm.py +0 -0
  23. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/otpd.py +0 -0
  24. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm/py.typed +0 -0
  25. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/__init__.py +0 -0
  26. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/__main__.py +0 -0
  27. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/app.py +0 -0
  28. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/auth/__init__.py +0 -0
  29. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/auth/functions.py +0 -0
  30. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/auth/models.py +0 -0
  31. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/auth/router.py +0 -0
  32. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/common/__init__.py +0 -0
  33. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/common/functions.py +0 -0
  34. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/common/metrics.py +0 -0
  35. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/common/models.py +0 -0
  36. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/common/router.py +0 -0
  37. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/settings.py +0 -0
  38. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/terminal/__init__.py +0 -0
  39. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/terminal/router.py +0 -0
  40. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_maintenance/__init__.py +0 -0
  41. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_maintenance/functions.py +0 -0
  42. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_maintenance/models.py +0 -0
  43. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_maintenance/router.py +0 -0
  44. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_otcs/__init__.py +0 -0
  45. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_otcs/functions.py +0 -0
  46. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_otcs/router.py +0 -0
  47. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_payload/__init__.py +0 -0
  48. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_payload/functions.py +0 -0
  49. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_payload/models.py +0 -0
  50. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_api/v1_payload/router.py +0 -0
  51. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/__init__.py +0 -0
  52. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/__main__.py +0 -0
  53. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/browser_automation.py +0 -0
  54. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/customizer.py +0 -0
  55. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/exceptions.py +0 -0
  56. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/guidewire.py +0 -0
  57. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/k8s.py +0 -0
  58. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/log.py +0 -0
  59. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/m365.py +0 -0
  60. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/payload_list.py +0 -0
  61. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/salesforce.py +0 -0
  62. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/sap.py +0 -0
  63. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/servicenow.py +0 -0
  64. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/settings.py +0 -0
  65. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/successfactors.py +0 -0
  66. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_customizer/translate.py +0 -0
  67. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_maintenance_page/__init__.py +0 -0
  68. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_maintenance_page/__main__.py +0 -0
  69. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_maintenance_page/app.py +0 -0
  70. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_maintenance_page/settings.py +0 -0
  71. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_maintenance_page/static/favicon.avif +0 -0
  72. {pyxecm-3.4.7 → pyxecm-3.4.9}/src/pyxecm_maintenance_page/templates/maintenance.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyxecm
3
- Version: 3.4.7
3
+ Version: 3.4.9
4
4
  Summary: A Python library to interact with Opentext Content Management Rest API
5
5
  Project-URL: Homepage, https://github.com/opentext/pyxecm
6
6
  Author-email: Kai Gatzweiler <kgatzweiler@opentext.com>, "Dr. Marc Diefenbruch" <mdiefenb@opentext.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyxecm"
3
- version = "3.4.7"
3
+ version = "3.4.9"
4
4
  description = 'A Python library to interact with Opentext Content Management Rest API'
5
5
  keywords = [
6
6
  'opentext',
@@ -998,7 +998,7 @@ class OTCS:
998
998
  if not self._workspace_type_lookup or workspace_type_id not in self._workspace_type_lookup:
999
999
  return None
1000
1000
 
1001
- return self._workspace_type_lookup.get(workspace_type_id)["name"]
1001
+ return self._workspace_type_lookup.get(workspace_type_id)
1002
1002
 
1003
1003
  # end method definition
1004
1004
 
@@ -7185,13 +7185,21 @@ class OTCS:
7185
7185
  # if the set row does not exist we can break:
7186
7186
  break
7187
7187
  attribute_value = cat_data.get(key)
7188
+ # Skip empty set cells (None / empty string) so they don't
7189
+ # accidentally match (e.g. str(None) == "None" under substring).
7190
+ # We keep checking the remaining set rows.
7191
+ if attribute_value is None or attribute_value == "":
7192
+ continue
7193
+ # Compare as strings to be type-tolerant: the lookup value is
7194
+ # always a string (e.g. from bulk payload placeholders) while the
7195
+ # stored attribute value can be an int/float for numeric attributes.
7188
7196
  # Is it a multi-value attribute (i.e. a list of values)?
7189
7197
  if isinstance(attribute_value, list):
7190
- if value in attribute_value:
7198
+ if any(str(value) == str(item) for item in attribute_value):
7191
7199
  node_matched = True
7192
7200
  break
7193
- elif (substring and value in attribute_value) or (
7194
- not substring and value == attribute_value
7201
+ elif (substring and str(value) in str(attribute_value)) or (
7202
+ not substring and str(value) == str(attribute_value)
7195
7203
  ):
7196
7204
  node_matched = True
7197
7205
  break
@@ -7199,14 +7207,19 @@ class OTCS:
7199
7207
  else:
7200
7208
  key = prefix + attribute_id
7201
7209
  attribute_value = cat_data.get(key)
7202
- if not attribute_value:
7210
+ # Note: we intentionally do not use "if not attribute_value" here as
7211
+ # that would also skip a legitimate numeric value of 0.
7212
+ if attribute_value is None or attribute_value == "":
7203
7213
  continue
7214
+ # Compare as strings to be type-tolerant (see comment above).
7204
7215
  # Is it a multi-value attribute (i.e. a list of values)?
7205
7216
  if isinstance(attribute_value, list):
7206
- if value in attribute_value:
7217
+ if any(str(value) == str(item) for item in attribute_value):
7207
7218
  node_matched = True
7208
7219
  # If not a multi-value attribute, check for equality:
7209
- elif (substring and value in attribute_value) or (not substring and value == attribute_value):
7220
+ elif (substring and str(value) in str(attribute_value)) or (
7221
+ not substring and str(value) == str(attribute_value)
7222
+ ):
7210
7223
  node_matched = True
7211
7224
  if node_matched:
7212
7225
  break
@@ -13587,15 +13600,15 @@ class OTCS:
13587
13600
 
13588
13601
  """
13589
13602
 
13590
- workspace_type = self._workspace_type_lookup.get(type_id)
13591
- if workspace_type and not renew:
13592
- return workspace_type.get("name")
13603
+ type_name = self._workspace_type_lookup.get(type_id)
13604
+ if type_name and not renew:
13605
+ return type_name
13593
13606
 
13594
13607
  workspace_type = self.get_workspace_type(type_id=type_id)
13595
13608
  type_name = workspace_type.get("workspace_type")
13596
13609
  if type_name:
13597
13610
  # Update the lookup cache:
13598
- self._workspace_type_lookup[type_id] = {"location": None, "name": type_name}
13611
+ self._workspace_type_lookup[type_id] = type_name
13599
13612
  return type_name
13600
13613
 
13601
13614
  return None
@@ -13639,6 +13652,165 @@ class OTCS:
13639
13652
 
13640
13653
  # end method definition
13641
13654
 
13655
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="get_workspace_type_lookup")
13656
+ def get_workspace_type_lookup(self, renew: bool = False) -> dict:
13657
+ """Get the lookup dictionary that maps workspace type IDs to their names.
13658
+
13659
+ The lookup maps each workspace type ID to its name,
13660
+ e.g. {848: 'Sales Contract'}.
13661
+ It is cached in the OTCS object variable self._workspace_type_lookup and
13662
+ also populated on demand by get_workspace_type_name().
13663
+
13664
+ Args:
13665
+ renew (bool):
13666
+ Whether to refresh the cached lookup via the REST API. If the
13667
+ cache is empty it is always populated regardless of this flag.
13668
+ Passing True re-fetches the current workspace types and overwrites
13669
+ existing entries. This is important because self._workspace_type_lookup
13670
+ has no expiry and is shared for the lifetime of this OTCS instance,
13671
+ so without a refresh a renamed workspace type would keep resolving
13672
+ to its stale old name here and via get_workspace_type_name().
13673
+
13674
+ Returns:
13675
+ dict:
13676
+ The workspace type lookup dictionary
13677
+ (type_id -> type_name).
13678
+
13679
+ Side effects:
13680
+ Caches the workspace type lookup in self._workspace_type_lookup
13681
+ for future calls.
13682
+
13683
+ """
13684
+
13685
+ if self._workspace_type_lookup and not renew:
13686
+ return self._workspace_type_lookup
13687
+
13688
+ workspace_types = self.get_workspace_types_iterator(expand_workspace_info=False, expand_templates=False)
13689
+ for workspace_type in workspace_types:
13690
+ wksp_type_id = self.get_result_value(response=workspace_type, key="wksp_type_id")
13691
+ wksp_type_name = self.get_result_value(response=workspace_type, key="wksp_type_name")
13692
+ # Overwrite (not just fill-if-missing) so a renamed workspace type does
13693
+ # not keep resolving to its stale old name here or via get_workspace_type_name():
13694
+ self._workspace_type_lookup[wksp_type_id] = wksp_type_name
13695
+
13696
+ return self._workspace_type_lookup
13697
+
13698
+ # end method definition
13699
+
13700
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="resolve_workspace_type_ids")
13701
+ def resolve_workspace_type_ids(self, workspace_types: str | int | list | None) -> set[int]:
13702
+ """Resolve workspace type names and/or IDs into a set of workspace type IDs.
13703
+
13704
+ Items may be workspace type IDs (int) or workspace type names (str). Names are
13705
+ resolved via the workspace type lookup (self._workspace_type_lookup, populated
13706
+ on demand via get_workspace_type_lookup()). Unresolvable names are silently
13707
+ skipped.
13708
+
13709
+ Args:
13710
+ workspace_types (str | int | list | None):
13711
+ A single workspace type name or ID, or a list of them.
13712
+ None or empty yields an empty set.
13713
+
13714
+ Returns:
13715
+ set[int]:
13716
+ The set of resolved workspace type IDs.
13717
+
13718
+ """
13719
+
13720
+ if not workspace_types:
13721
+ return set()
13722
+ if isinstance(workspace_types, (str, int)):
13723
+ workspace_types = [workspace_types]
13724
+
13725
+ name_to_id = {name: type_id for type_id, name in self.get_workspace_type_lookup().items()}
13726
+
13727
+ type_ids = set()
13728
+ for item in workspace_types:
13729
+ if isinstance(item, int):
13730
+ type_ids.add(item)
13731
+ elif item in name_to_id:
13732
+ type_ids.add(name_to_id[item])
13733
+
13734
+ return type_ids
13735
+
13736
+ # end method definition
13737
+
13738
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="resolve_workspace_type_filter")
13739
+ def resolve_workspace_type_filter(
13740
+ self,
13741
+ workspace_type_inclusions: str | int | list | None = None,
13742
+ workspace_type_exclusions: str | int | list | None = None,
13743
+ ) -> list[int] | None:
13744
+ """Resolve inclusion/exclusion workspace type filters into a list of allowed type IDs.
13745
+
13746
+ This is intended for server-side pre-filtering (e.g. the related_workspace_type_id
13747
+ parameter of get_workspace_relationships_iterator()). Inclusions define the base set
13748
+ (all workspace types if no inclusions are given), exclusions are then removed from it.
13749
+
13750
+ If an inclusion name cannot be resolved to a workspace type ID we cannot safely narrow
13751
+ server-side (we would risk dropping matching workspaces), so we return None so the
13752
+ caller can fall back to client-side filtering (see _check_filter()).
13753
+
13754
+ Args:
13755
+ workspace_type_inclusions (str | int | list | None):
13756
+ Workspace type names and/or IDs to include. None/empty = include all types.
13757
+ workspace_type_exclusions (str | int | list | None):
13758
+ Workspace type names and/or IDs to exclude.
13759
+
13760
+ Returns:
13761
+ list[int] | None:
13762
+ The list of allowed workspace type IDs, or None if the filter cannot be
13763
+ resolved to a safe server-side filter (caller should not narrow server-side).
13764
+
13765
+ """
13766
+
13767
+ name_to_id = {name: type_id for type_id, name in self.get_workspace_type_lookup().items()}
13768
+
13769
+ # Normalize scalars to lists so we can inspect the inclusion names below:
13770
+ if isinstance(workspace_type_inclusions, (str, int)):
13771
+ workspace_type_inclusions = [workspace_type_inclusions]
13772
+ if isinstance(workspace_type_exclusions, (str, int)):
13773
+ workspace_type_exclusions = [workspace_type_exclusions]
13774
+
13775
+ # Inclusions define the base set (all types if no inclusions are given),
13776
+ # exclusions are then removed from it:
13777
+ included_ids = (
13778
+ self.resolve_workspace_type_ids(workspace_type_inclusions)
13779
+ if workspace_type_inclusions
13780
+ else set(name_to_id.values())
13781
+ )
13782
+ excluded_ids = self.resolve_workspace_type_ids(workspace_type_exclusions)
13783
+ allowed_ids = included_ids - excluded_ids
13784
+
13785
+ # If an inclusion name could not be resolved to a type ID we cannot safely
13786
+ # narrow server-side (we would risk dropping matching workspaces):
13787
+ unresolved_names = (
13788
+ [item for item in workspace_type_inclusions if isinstance(item, str) and item not in name_to_id]
13789
+ if workspace_type_inclusions
13790
+ else []
13791
+ )
13792
+ if unresolved_names:
13793
+ self.logger.warning(
13794
+ "Cannot resolve workspace type name(s) -> %s to a workspace type ID "
13795
+ "(available types -> %s). Falling back to client-side filtering.",
13796
+ unresolved_names,
13797
+ sorted(name_to_id.keys()),
13798
+ )
13799
+ return None
13800
+
13801
+ if not allowed_ids:
13802
+ self.logger.debug(
13803
+ "Workspace type filter (inclusions -> %s, exclusions -> %s) resolved to an empty set of allowed "
13804
+ "types. Falling back to client-side filtering.",
13805
+ workspace_type_inclusions,
13806
+ workspace_type_exclusions,
13807
+ )
13808
+ return None
13809
+
13810
+ return list(allowed_ids)
13811
+
13812
+ # end method definition
13813
+
13642
13814
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="update_workspace_type_relations")
13643
13815
  def update_workspace_type_relations(
13644
13816
  self,
@@ -15220,7 +15392,7 @@ class OTCS:
15220
15392
  parse_error_response=parse_error_response,
15221
15393
  )
15222
15394
 
15223
- if "error" in response:
15395
+ if response and "error" in response:
15224
15396
  return response
15225
15397
 
15226
15398
  node_id = self.get_result_value(response=response, key="id")
@@ -24416,6 +24588,31 @@ class OTCS:
24416
24588
  if isinstance(workspace_type_inclusions, str):
24417
24589
  workspace_type_inclusions = [workspace_type_inclusions]
24418
24590
 
24591
+ # Precompute the full workspace type lookup (type_id -> name) up-front
24592
+ # (renew=True to pick up any renamed types on every traversal run). This lets us:
24593
+ # 1. Resolve the server-side related workspace type filter deterministically
24594
+ # before any worker starts (no race with the queue initializer thread).
24595
+ # 2. Have get_workspace_type_name() calls during traversal hit the cache.
24596
+ # 3. Iterate the workspace types for queue initialization without an
24597
+ # extra REST round-trip.
24598
+ self.get_workspace_type_lookup(renew=True)
24599
+
24600
+ # Resolve the server-side related workspace type filter once, now that the
24601
+ # full workspace type lookup is available. When filter_at_traversal is False
24602
+ # we keep this None so the relationship iterator is not narrowed server-side:
24603
+ # passing the full list of all workspace type IDs would only slow down the
24604
+ # server-side query without excluding anything (the REST API then returns all
24605
+ # related workspaces - the previous behavior). When it is True we resolve the
24606
+ # inclusion/exclusion filters (which may contain type names or type IDs) into a
24607
+ # concrete list of allowed workspace type IDs so the server pre-filters. The
24608
+ # client-side _check_filter() still runs as a safety net.
24609
+ related_type_id_filter: list[int] | None = None
24610
+ if filter_at_traversal:
24611
+ related_type_id_filter = self.resolve_workspace_type_filter(
24612
+ workspace_type_inclusions=workspace_type_inclusions,
24613
+ workspace_type_exclusions=workspace_type_exclusions,
24614
+ )
24615
+
24419
24616
  lock = threading.Lock()
24420
24617
  if strategy == "BFS":
24421
24618
  task_queue = Queue()
@@ -24435,19 +24632,11 @@ class OTCS:
24435
24632
 
24436
24633
  self.logger.debug("Initialize traversal queue...")
24437
24634
 
24438
- # Enqueue initial nodes at depth 0:
24439
- workspace_types = self.get_workspace_types_iterator(expand_workspace_info=False, expand_templates=False)
24440
- for workspace_type in workspace_types:
24441
- wksp_type_id = self.get_result_value(response=workspace_type, key="wksp_type_id")
24442
- wksp_type_name = self.get_result_value(response=workspace_type, key="wksp_type_name")
24443
- # Refresh (not just fill-if-missing): this is a fresh, current listing of
24444
- # workspace types, fetched on every traversal run. self._workspace_type_lookup
24445
- # is a long-lived cache on this OTCS instance (spans many traversal runs over the
24446
- # life of the process), so if we only filled in missing entries, a renamed
24447
- # workspace type would keep resolving to its stale old name here - and via
24448
- # get_workspace_type_name() - forever. Overwriting is free since we already have
24449
- # the current name from this iteration; no extra API call is needed.
24450
- self._workspace_type_lookup[wksp_type_id] = {"location": None, "name": wksp_type_name}
24635
+ # Enqueue initial nodes at depth 0. We reuse the workspace type lookup
24636
+ # (self._workspace_type_lookup) that was fully populated before the
24637
+ # workers started, so we neither re-query the REST API nor need to
24638
+ # repopulate the lookup here:
24639
+ for wksp_type_id, wksp_type_name in self._workspace_type_lookup.items():
24451
24640
  if not self._check_filter(
24452
24641
  workspace_type_name=wksp_type_name,
24453
24642
  workspace_type_id=wksp_type_id,
@@ -24589,7 +24778,11 @@ class OTCS:
24589
24778
  for rel_type in relationship_types:
24590
24779
  # Get related workspaces of the current workspace and the current relationship type:
24591
24780
  workspace_relationships = self.get_workspace_relationships_iterator(
24592
- workspace_id=workspace_id, relationship_type=rel_type, fields=fields, metadata=metadata
24781
+ workspace_id=workspace_id,
24782
+ relationship_type=rel_type,
24783
+ related_workspace_type_id=related_type_id_filter,
24784
+ fields=fields,
24785
+ metadata=metadata,
24593
24786
  )
24594
24787
 
24595
24788
  # Traverse all related workspaces:
@@ -957,6 +957,100 @@ class OTDS:
957
957
 
958
958
  # end method definition
959
959
 
960
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="authenticate_user_token")
961
+ def authenticate_user_token(
962
+ self,
963
+ username: str,
964
+ password: str,
965
+ scope: str = "otds:roles",
966
+ ) -> str | None:
967
+ """Authenticate a user via the OAuth2 password grant and return a JWT access token.
968
+
969
+ Unlike :meth:`authenticate` with ``grant_type="password"`` (which hits the
970
+ legacy credential endpoint and returns an OTDS SSO ticket), this method posts
971
+ ``grant_type=password`` together with the configured OAuth client credentials to
972
+ the OAuth2 token endpoint (``/otdsws/oauth2/token``). This produces a signed
973
+ JWT access token that can be used as a standard ``Authorization: Bearer`` token.
974
+
975
+ The ``client_id`` and ``client_secret`` must be configured on the OTDS object
976
+ (passed to the constructor) before calling this method.
977
+
978
+ Args:
979
+ username (str):
980
+ The OTDS login name of the user to authenticate.
981
+ password (str):
982
+ The password for the OTDS user.
983
+ scope (str, optional):
984
+ OAuth2 scope string to request. Defaults to ``"otds:roles"``.
985
+
986
+ Returns:
987
+ str | None:
988
+ The JWT access token, or ``None`` if authentication fails.
989
+ The token is also stored in ``self._token``.
990
+
991
+ Example:
992
+ 'eyJraWQiOiJvdGRzLXNpZ25pbmcta2V5IiwiYWxnIjoiUlMyNTYifQ...'
993
+
994
+ """
995
+
996
+ request_url = self.token_url()
997
+
998
+ request_data = {
999
+ "grant_type": "password",
1000
+ "client_id": self.config()["clientId"],
1001
+ "client_secret": self.config()["clientSecret"],
1002
+ "username": username,
1003
+ "password": password,
1004
+ "scope": scope,
1005
+ }
1006
+
1007
+ self.logger.debug(
1008
+ "Requesting OTDS user access token for -> '%s'; calling -> %s",
1009
+ username,
1010
+ request_url,
1011
+ )
1012
+
1013
+ try:
1014
+ response = requests.post(
1015
+ url=request_url,
1016
+ headers=REQUEST_FORM_HEADERS,
1017
+ data=request_data,
1018
+ timeout=REQUEST_TIMEOUT,
1019
+ )
1020
+ except requests.exceptions.RequestException as exception:
1021
+ self.logger.warning(
1022
+ "Unable to connect to OTDS token endpoint -> %s; error -> %s",
1023
+ request_url,
1024
+ str(exception),
1025
+ )
1026
+ return None
1027
+
1028
+ if not response.ok:
1029
+ self.logger.error(
1030
+ "Failed to request user access token for -> '%s'; error -> %s",
1031
+ username,
1032
+ response.text,
1033
+ )
1034
+ return None
1035
+
1036
+ token_dict = self.parse_request_response(response)
1037
+ if not token_dict:
1038
+ return None
1039
+
1040
+ access_token = token_dict.get("access_token")
1041
+ if not access_token:
1042
+ self.logger.error(
1043
+ "OTDS token response for user -> '%s' did not contain an access_token",
1044
+ username,
1045
+ )
1046
+ return None
1047
+
1048
+ self._token = access_token
1049
+ self._cookie = None
1050
+ return self._token
1051
+
1052
+ # end method definition
1053
+
960
1054
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="impersonate_user")
961
1055
  def impersonate_user(
962
1056
  self,
@@ -28177,7 +28177,9 @@ class Payload:
28177
28177
  attribute_name=att_name,
28178
28178
  set_name=set_name,
28179
28179
  )
28180
- if key == conflicting_key:
28180
+ # Compare as strings to be type-tolerant: 'key' is always a string
28181
+ # while a numeric category attribute value is returned as an int/float.
28182
+ if str(key) == str(conflicting_key):
28181
28183
  # We have a race condition as the two documents don't really clash but are identical.
28182
28184
  # Just skip uploading the same document once more.
28183
28185
  self.logger.warning(
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes