pyxecm 2.0.2__tar.gz → 2.0.4__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 pyxecm might be problematic. Click here for more details.

Files changed (85) hide show
  1. {pyxecm-2.0.2/pyxecm.egg-info → pyxecm-2.0.4}/PKG-INFO +2 -2
  2. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyproject.toml +21 -2
  3. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/__init__.py +3 -2
  4. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/avts.py +3 -1
  5. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/coreshare.py +71 -5
  6. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/app.py +7 -13
  7. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/auth/functions.py +37 -30
  8. pyxecm-2.0.4/pyxecm/customizer/api/common/functions.py +101 -0
  9. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/common/payload_list.py +39 -10
  10. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/common/router.py +55 -6
  11. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/settings.py +14 -3
  12. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/terminal/router.py +43 -18
  13. pyxecm-2.0.4/pyxecm/customizer/api/v1_csai/models.py +18 -0
  14. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_csai/router.py +26 -1
  15. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_otcs/router.py +16 -6
  16. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_payload/functions.py +9 -3
  17. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/browser_automation.py +506 -199
  18. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/customizer.py +123 -22
  19. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/guidewire.py +170 -37
  20. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/payload.py +723 -330
  21. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/settings.py +21 -3
  22. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/translate.py +14 -10
  23. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/helper/data.py +12 -20
  24. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/helper/xml.py +1 -1
  25. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/maintenance_page/app.py +6 -2
  26. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otawp.py +10 -6
  27. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otca.py +187 -21
  28. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otcs.py +2424 -415
  29. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otds.py +4 -11
  30. pyxecm-2.0.4/pyxecm/otkd.py +1369 -0
  31. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otmm.py +190 -66
  32. {pyxecm-2.0.2 → pyxecm-2.0.4/pyxecm.egg-info}/PKG-INFO +2 -2
  33. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm.egg-info/SOURCES.txt +2 -0
  34. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm.egg-info/requires.txt +1 -1
  35. pyxecm-2.0.2/pyxecm/customizer/api/common/functions.py +0 -47
  36. {pyxecm-2.0.2 → pyxecm-2.0.4}/LICENSE +0 -0
  37. {pyxecm-2.0.2 → pyxecm-2.0.4}/README.md +0 -0
  38. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/__init__.py +0 -0
  39. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/__main__.py +0 -0
  40. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/__init__.py +0 -0
  41. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/__main__.py +0 -0
  42. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/auth/__init__.py +0 -0
  43. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/auth/models.py +0 -0
  44. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/auth/router.py +0 -0
  45. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/common/__init__.py +0 -0
  46. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/common/metrics.py +0 -0
  47. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/common/models.py +0 -0
  48. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/terminal/__init__.py +0 -0
  49. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_csai/__init__.py +0 -0
  50. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_maintenance/__init__.py +0 -0
  51. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_maintenance/functions.py +0 -0
  52. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_maintenance/models.py +0 -0
  53. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_maintenance/router.py +0 -0
  54. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_otcs/__init__.py +0 -0
  55. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_otcs/functions.py +0 -0
  56. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_payload/__init__.py +0 -0
  57. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_payload/models.py +0 -0
  58. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/api/v1_payload/router.py +0 -0
  59. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/exceptions.py +0 -0
  60. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/k8s.py +0 -0
  61. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/log.py +0 -0
  62. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/m365.py +0 -0
  63. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/nhc.py +0 -0
  64. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/openapi.py +0 -0
  65. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/pht.py +0 -0
  66. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/salesforce.py +0 -0
  67. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/sap.py +0 -0
  68. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/servicenow.py +0 -0
  69. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/customizer/successfactors.py +0 -0
  70. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/helper/__init__.py +0 -0
  71. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/helper/assoc.py +0 -0
  72. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/helper/logadapter.py +0 -0
  73. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/helper/web.py +0 -0
  74. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/maintenance_page/__init__.py +0 -0
  75. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/maintenance_page/__main__.py +0 -0
  76. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/maintenance_page/settings.py +0 -0
  77. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/maintenance_page/static/favicon.avif +0 -0
  78. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/maintenance_page/templates/maintenance.html +0 -0
  79. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otac.py +0 -0
  80. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otiv.py +0 -0
  81. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm/otpd.py +0 -0
  82. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm.egg-info/dependency_links.txt +0 -0
  83. {pyxecm-2.0.2 → pyxecm-2.0.4}/pyxecm.egg-info/top_level.txt +0 -0
  84. {pyxecm-2.0.2 → pyxecm-2.0.4}/setup.cfg +0 -0
  85. {pyxecm-2.0.2 → pyxecm-2.0.4}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyxecm
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: A Python library to interact with Opentext Extended ECM REST API
5
5
  Author-email: Kai Gatzweiler <kgatzweiler@opentext.com>, "Dr. Marc Diefenbruch" <mdiefenb@opentext.com>
6
6
  Project-URL: Homepage, https://github.com/opentext/pyxecm
@@ -46,7 +46,7 @@ Requires-Dist: shapely; extra == "dataloader"
46
46
  Requires-Dist: cartopy; extra == "dataloader"
47
47
  Requires-Dist: psycopg; extra == "dataloader"
48
48
  Provides-Extra: sap
49
- Requires-Dist: pyrfc==2.8.3; extra == "sap"
49
+ Requires-Dist: pyrfc==3.3.1; extra == "sap"
50
50
  Provides-Extra: profiling
51
51
  Requires-Dist: pyinstrument; extra == "profiling"
52
52
  Dynamic: license-file
@@ -1,3 +1,4 @@
1
+
1
2
  [build-system]
2
3
  build-backend = 'setuptools.build_meta'
3
4
  requires = ['setuptools >= 61.0']
@@ -48,7 +49,7 @@ keywords = [
48
49
  name = 'pyxecm'
49
50
  readme = 'README.md'
50
51
  requires-python = '>=3.10'
51
- version = "2.0.2"
52
+ version = "2.0.4"
52
53
 
53
54
  [[project.authors]]
54
55
  email = 'kgatzweiler@opentext.com'
@@ -68,7 +69,7 @@ dataloader = [
68
69
  'cartopy',
69
70
  'psycopg',
70
71
  ]
71
- sap = ['pyrfc==2.8.3']
72
+ sap = ['pyrfc==3.3.1']
72
73
  profiling = ['pyinstrument']
73
74
 
74
75
  [project.urls]
@@ -176,8 +177,26 @@ dev = [
176
177
  "ruff",
177
178
  'python-decouple',
178
179
  'settings-doc',
180
+ "pytest>=8.3.5",
181
+ "pytest-mock>=3.14.0",
182
+ "pytest-cov>=6.1.1",
183
+ "httpx>=0.28.1",
184
+ "requests-mock>=1.12.1",
179
185
  ]
180
186
 
181
187
  [tool.ruff]
182
188
  line-length = 120
183
189
  exclude = []
190
+
191
+ [tool.ruff.lint.extend-per-file-ignores]
192
+ "tests/**/*.py" = [
193
+ "INP001","D100","D103","ANN",
194
+ "S101", # asserts allowed in tests...
195
+ "ARG", # Unused function args -> fixtures nevertheless are functionally relevant...
196
+ "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize()
197
+ "PLR2004", # Magic value used in comparison, ...
198
+ "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
199
+ ]
200
+ "**/__init__.py" = [
201
+ "D104"
202
+ ]
@@ -1,4 +1,4 @@
1
- """pyxecm - A python library to interact with Opentext Extended ECM REST API."""
1
+ """pyxecm - A python library to interact with Opentext REST APIs."""
2
2
 
3
3
  from .avts import AVTS
4
4
  from .coreshare import CoreShare
@@ -8,7 +8,8 @@ from .otca import OTCA
8
8
  from .otcs import OTCS
9
9
  from .otds import OTDS
10
10
  from .otiv import OTIV
11
+ from .otkd import OTKD
11
12
  from .otmm import OTMM
12
13
  from .otpd import OTPD
13
14
 
14
- __all__ = ["AVTS", "OTAC", "OTAWP", "OTCA", "OTCS", "OTDS", "OTIV", "OTMM", "OTPD", "CoreShare"]
15
+ __all__ = ["AVTS", "OTAC", "OTAWP", "OTCA", "OTCS", "OTDS", "OTIV", "OTKD", "OTMM", "OTPD", "CoreShare"]
@@ -396,8 +396,10 @@ class AVTS:
396
396
 
397
397
  if response is not None:
398
398
  self._accesstoken = response.get("access_token", None)
399
+ else:
400
+ self._accesstoken = None
399
401
 
400
- return response
402
+ return self._accesstoken
401
403
 
402
404
  # end method definition
403
405
 
@@ -27,6 +27,7 @@ import time
27
27
  import urllib.parse
28
28
  from http import HTTPStatus
29
29
  from importlib.metadata import version
30
+ from urllib.parse import parse_qs, urlparse
30
31
 
31
32
  import requests
32
33
 
@@ -522,7 +523,7 @@ class CoreShare:
522
523
  Can be used to provide a more specific error message
523
524
  in case an error occurs.
524
525
  show_error (bool, optional):
525
- True: write an error to the log file
526
+ True: write an error to the log file (this is the default)
526
527
  False: write a warning to the log file
527
528
 
528
529
  Returns:
@@ -933,6 +934,71 @@ class CoreShare:
933
934
 
934
935
  # end method definition
935
936
 
937
+ def get_groups_iterator(
938
+ self,
939
+ count: int | None = None,
940
+ ) -> iter:
941
+ """Get an iterator object that can be used to traverse all Core Share groups.
942
+
943
+ Returning a generator avoids loading a large number of items into memory at once. Instead you
944
+ can iterate over the potential large list of Core Share groups.
945
+
946
+ Example usage:
947
+ groups = core_share_object.get_groups_iterator(page_size=10)
948
+ for group in groups:
949
+ logger.info("Traversing Core Share group -> %s", group["name"])
950
+
951
+ Args:
952
+ count (int | None, optional):
953
+ The chunk size for the number of groups returned by one
954
+ REST API call. If None, then a default of 250 is used.
955
+
956
+ Returns:
957
+ iter:
958
+ A generator yielding one OTDS group per iteration.
959
+ If the REST API fails, returns no value.
960
+
961
+ """
962
+
963
+ offset = 0
964
+
965
+ while True:
966
+ response = self.get_groups(
967
+ offset=offset,
968
+ count=count,
969
+ )
970
+ if not response or not response.get("results", []):
971
+ # Don't return None! Plain return is what we need for iterators.
972
+ # Natural Termination: If the generator does not yield, it behaves
973
+ # like an empty iterable when used in a loop or converted to a list:
974
+ return
975
+
976
+ # Yield users one at a time:
977
+ yield from response["results"]
978
+
979
+ # See if we have an additional result page.
980
+ # If not terminate the iterator and return
981
+ # no value.
982
+
983
+ next_page_url = response["_links"].get("next")
984
+ if not next_page_url:
985
+ # Don't return None! Plain return is what we need for iterators.
986
+ # Natural Termination: If the generator does not yield, it behaves
987
+ # like an empty iterable when used in a loop or converted to a list:
988
+ return
989
+ next_page_url = next_page_url.get("href")
990
+
991
+ # Extract the query string from the URL
992
+ query = urlparse(next_page_url).query
993
+
994
+ # Parse the query parameters into a dictionary
995
+ params = parse_qs(query)
996
+
997
+ # Get the 'offset' value as an integer (it's a list by default)
998
+ offset = int(params.get("offset", [0])[0])
999
+
1000
+ # end method definition
1001
+
936
1002
  def add_group(
937
1003
  self,
938
1004
  group_name: str,
@@ -1276,7 +1342,7 @@ class CoreShare:
1276
1342
  dict | None:
1277
1343
  Dictionary with the Core Share group data or None if the request fails.
1278
1344
 
1279
- Example result:
1345
+ Example Response:
1280
1346
  {
1281
1347
  'results': [
1282
1348
  {
@@ -1343,15 +1409,15 @@ class CoreShare:
1343
1409
 
1344
1410
  # end method definition
1345
1411
 
1346
- def get_users(self) -> dict | None:
1412
+ def get_users(self) -> list | None:
1347
1413
  """Get Core Share users.
1348
1414
 
1349
1415
  Args:
1350
1416
  None
1351
1417
 
1352
1418
  Returns:
1353
- dict | None:
1354
- Dictionary with the Core Share user data or None if the request fails.
1419
+ list | None:
1420
+ List with the Core Share user data or None if the request fails.
1355
1421
 
1356
1422
  Example response (it is a list!):
1357
1423
  [
@@ -13,7 +13,6 @@ from collections.abc import AsyncGenerator
13
13
  from contextlib import asynccontextmanager
14
14
  from datetime import datetime, timezone
15
15
  from importlib.metadata import version
16
- from threading import Thread
17
16
 
18
17
  import uvicorn
19
18
  from fastapi import FastAPI
@@ -85,17 +84,11 @@ async def lifespan(
85
84
  # Optional Payload
86
85
  import_payload(payload_dir=api_settings.payload_dir_optional)
87
86
 
88
- if api_settings.maintenance_mode:
89
- logger.info("Starting maintenance_page thread...")
90
- maint_thread = Thread(target=run_maintenance_page, name="maintenance_page")
91
- maint_thread.start()
87
+ logger.info("Starting maintenance_page thread...")
88
+ run_maintenance_page()
92
89
 
93
90
  logger.info("Starting processing thread...")
94
- thread = Thread(
95
- target=PAYLOAD_LIST.run_payload_processing,
96
- name="customization_run_api",
97
- )
98
- thread.start()
91
+ PAYLOAD_LIST.run_payload_processing(concurrent=api_settings.concurrent_payloads)
99
92
 
100
93
  yield
101
94
  logger.info("Shutdown")
@@ -104,8 +97,10 @@ async def lifespan(
104
97
 
105
98
  app = FastAPI(
106
99
  docs_url="/api",
107
- title="Customizer API",
108
- openapi_url="/api/openapi.json",
100
+ title=api_settings.title,
101
+ description=api_settings.description,
102
+ openapi_url=api_settings.openapi_url,
103
+ root_path=api_settings.root_path,
109
104
  lifespan=lifespan,
110
105
  version=version("pyxecm"),
111
106
  openapi_tags=[
@@ -135,7 +130,6 @@ if api_settings.ws_terminal:
135
130
  if api_settings.csai:
136
131
  app.include_router(router=v1_csai_router)
137
132
 
138
-
139
133
  logger = logging.getLogger("CustomizerAPI")
140
134
  app.add_middleware(
141
135
  CORSMiddleware,
@@ -5,14 +5,20 @@ from typing import Annotated
5
5
 
6
6
  import requests
7
7
  from fastapi import APIRouter, Depends, HTTPException, status
8
- from fastapi.security import OAuth2PasswordBearer
8
+ from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
9
9
 
10
10
  from pyxecm.customizer.api.auth.models import User
11
11
  from pyxecm.customizer.api.settings import api_settings
12
12
 
13
13
  router = APIRouter()
14
14
 
15
- oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
15
+ oauth2_scheme = OAuth2PasswordBearer(
16
+ tokenUrl="token",
17
+ auto_error=False,
18
+ description="Authenticate with OTDS, user needs to be member of 'otadmins@otds.admin' group",
19
+ scheme_name="OTDS Authentication",
20
+ )
21
+ apikey_header = APIKeyHeader(name="x-api-key", auto_error=False, scheme_name="APIKey")
16
22
 
17
23
 
18
24
  def get_groups(response: dict, token: str) -> list:
@@ -42,43 +48,44 @@ def get_groups(response: dict, token: str) -> list:
42
48
  return []
43
49
 
44
50
 
45
- async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
51
+ async def get_current_user(
52
+ token: Annotated[str, Depends(oauth2_scheme)], api_key: Annotated[str, Depends(apikey_header)]
53
+ ) -> User:
46
54
  """Get the current user from OTDS and verify it."""
47
55
 
48
- if api_settings.api_token is not None and token == api_settings.api_token:
56
+ if api_settings.api_key is not None and api_key == api_settings.api_key:
49
57
  return User(
50
58
  id="api",
51
- full_name="API Token",
59
+ full_name="API Key",
52
60
  groups=["otadmins@otds.admin"],
53
61
  is_admin=True,
54
62
  is_sysadmin=True,
55
63
  )
56
64
 
57
- url = api_settings.otds_url + "/otdsws/rest/currentuser"
58
- headers = {
59
- "Accept": "application/json",
60
- "otdsticket": token,
61
- }
62
- response = requests.request("GET", url, headers=headers, timeout=2)
63
-
64
- if response.ok:
65
- response = json.loads(response.text)
66
-
67
- user = User(
68
- id=response["user"]["id"],
69
- full_name=response["user"]["name"],
70
- groups=get_groups(response, token),
71
- is_admin=response["isAdmin"],
72
- is_sysadmin=response["isSysAdmin"],
73
- )
74
-
75
- return user
76
- else:
77
- raise HTTPException(
78
- status_code=status.HTTP_401_UNAUTHORIZED,
79
- detail="Invalid authentication credentials",
80
- headers={"WWW-Authenticate": "Bearer"},
81
- )
65
+ if token and token.startswith("*OTDSSSO*"):
66
+ url = api_settings.otds_url + "/otdsws/rest/currentuser"
67
+ headers = {
68
+ "Accept": "application/json",
69
+ "otdsticket": token,
70
+ }
71
+ response = requests.request("GET", url, headers=headers, timeout=2)
72
+
73
+ if response.ok:
74
+ response = json.loads(response.text)
75
+
76
+ return User(
77
+ id=response["user"]["id"],
78
+ full_name=response["user"]["name"],
79
+ groups=get_groups(response, token),
80
+ is_admin=response["isAdmin"],
81
+ is_sysadmin=response["isSysAdmin"],
82
+ )
83
+
84
+ raise HTTPException(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ detail="Invalid authentication credentials",
87
+ headers={"WWW-Authenticate": "Bearer"},
88
+ )
82
89
 
83
90
 
84
91
  async def get_authorized_user(current_user: Annotated[User, Depends(get_current_user)]) -> User:
@@ -0,0 +1,101 @@
1
+ """Define common functions."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ from pyxecm.customizer.api.common.payload_list import PayloadList
7
+ from pyxecm.customizer.api.settings import CustomizerAPISettings, api_settings
8
+ from pyxecm.customizer.k8s import K8s
9
+ from pyxecm.customizer.settings import Settings
10
+ from pyxecm.otcs import OTCS
11
+
12
+ logger = logging.getLogger("pyxecm.customizer.api")
13
+
14
+ # Create a LOCK dict for singleton logs collection
15
+ LOGS_LOCK = {}
16
+ # Initialize the globel Payloadlist object
17
+ PAYLOAD_LIST = PayloadList(logger=logger)
18
+
19
+
20
+ def get_k8s_object() -> K8s:
21
+ """Get an instance of a K8s object.
22
+
23
+ Returns:
24
+ K8s: Return a K8s object
25
+
26
+ """
27
+
28
+ return K8s(logger=logger, namespace=api_settings.namespace)
29
+
30
+
31
+ def get_otcs_object() -> OTCS:
32
+ """Get an instance of a K8s object.
33
+
34
+ Returns:
35
+ K8s: Return a K8s object
36
+
37
+ """
38
+ settings = Settings()
39
+
40
+ cs = OTCS(
41
+ protocol=settings.otcs.url_backend.scheme,
42
+ hostname=settings.otcs.url_backend.host,
43
+ port=settings.otcs.url_backend.port,
44
+ public_url=str(settings.otcs.url),
45
+ username=settings.otcs.username,
46
+ password=settings.otcs.password.get_secret_value(),
47
+ user_partition=settings.otcs.partition,
48
+ resource_name=settings.otcs.resource_name,
49
+ base_path=settings.otcs.base_path,
50
+ support_path=settings.otcs.support_path,
51
+ download_dir=settings.otcs.download_dir,
52
+ feme_uri=settings.otcs.feme_uri,
53
+ logger=logger,
54
+ )
55
+
56
+ cs.authenticate()
57
+
58
+ return cs
59
+
60
+
61
+ def get_settings() -> CustomizerAPISettings:
62
+ """Get the API Settings object.
63
+
64
+ Returns:
65
+ CustomizerPISettings: Returns the API Settings
66
+
67
+ """
68
+
69
+ return api_settings
70
+
71
+
72
+ def get_otcs_logs_lock() -> dict:
73
+ """Get the Logs LOCK dict.
74
+
75
+ Returns:
76
+ The dict with all LOCKS for the logs
77
+
78
+ """
79
+
80
+ return LOGS_LOCK
81
+
82
+
83
+ def list_files_in_directory(directory: str) -> dict:
84
+ """Recursively list files in a directory and return a nested JSON structure with URLs."""
85
+ result = {}
86
+ for root, dirs, files in os.walk(directory):
87
+ # Sort directories and files alphabetically
88
+ dirs.sort()
89
+ files.sort()
90
+
91
+ current_level = result
92
+ path_parts = root.split(os.sep)
93
+ relative_path = os.path.relpath(root, directory)
94
+ for part in path_parts[len(directory.split(os.sep)) :]:
95
+ if part not in current_level:
96
+ current_level[part] = {}
97
+ current_level = current_level[part]
98
+ for file in files:
99
+ file_path = os.path.join(relative_path, file)
100
+ current_level[file] = file_path
101
+ return result
@@ -609,7 +609,7 @@ class PayloadList:
609
609
 
610
610
  # Log each runnable item
611
611
  for _, row in runnable_df.iterrows():
612
- self.logger.info(
612
+ self.logger.debug(
613
613
  "Added payload file -> '%s' with index -> %s to runnable queue.",
614
614
  row["name"] if row["name"] else row["filename"],
615
615
  row["index"],
@@ -619,12 +619,35 @@ class PayloadList:
619
619
 
620
620
  # end method definition
621
621
 
622
- def process_payload_list(self) -> None:
622
+ def pick_running(self) -> int:
623
+ """Pick all PayloadItems with status "running".
624
+
625
+ Returns:
626
+ pd.DataFrame:
627
+ A list of running payload items.
628
+
629
+ """
630
+
631
+ if self.payload_items.empty:
632
+ return 0
633
+
634
+ all_status = self.payload_items["status"].value_counts().to_dict()
635
+
636
+ return all_status.get("running", 0)
637
+
638
+ # end method definition
639
+
640
+ def process_payload_list(self, concurrent: int | None = None) -> None:
623
641
  """Process runnable payloads.
624
642
 
643
+ Args:
644
+ concurrent (int | None, optional):
645
+ The maximum number of concurrent payloads to run at any given time.
646
+
625
647
  Continuously checks for runnable payload items and starts their
626
648
  "process_payload" method in separate threads.
627
649
  Runs as a daemon until the customizer ends.
650
+
628
651
  """
629
652
 
630
653
  def run_and_complete_payload(payload_item: pd.Series) -> None:
@@ -817,6 +840,18 @@ class PayloadList:
817
840
  # Start a thread for each runnable item (item is a pd.Series)
818
841
  if runnable_items is not None:
819
842
  for _, item in runnable_items.iterrows():
843
+ if concurrent and self.pick_running() >= concurrent:
844
+ self.logger.debug(
845
+ "Reached concurrency limit of %s payloads. Waiting for one to finish.",
846
+ )
847
+ break
848
+
849
+ self.logger.info(
850
+ "Added payload file -> '%s' with index -> %s to runnable queue.",
851
+ item["name"] if item["name"] else item["filename"],
852
+ item["index"],
853
+ )
854
+
820
855
  # Update the status to "running" in the data frame to prevent re-processing
821
856
  self.payload_items.loc[
822
857
  self.payload_items["name"] == item["name"],
@@ -837,13 +872,14 @@ class PayloadList:
837
872
 
838
873
  # end method definition
839
874
 
840
- def run_payload_processing(self) -> None:
875
+ def run_payload_processing(self, concurrent: int | None = None) -> None:
841
876
  """Start the `process_payload_list` method in a daemon thread."""
842
877
 
843
878
  scheduler_thread = threading.Thread(
844
879
  target=self.process_payload_list,
845
880
  daemon=True,
846
881
  name="Scheduler",
882
+ kwargs={"concurrent": concurrent},
847
883
  )
848
884
 
849
885
  self.logger.info(
@@ -853,13 +889,6 @@ class PayloadList:
853
889
  self._stopped = False
854
890
  scheduler_thread.start()
855
891
 
856
- self.logger.info(
857
- "Waiting for thread -> '%s' to complete...",
858
- str(scheduler_thread.name),
859
- )
860
- scheduler_thread.join()
861
- self.logger.info("Thread -> '%s' has completed.", str(scheduler_thread.name))
862
-
863
892
  # end method definition
864
893
 
865
894
  def stop_payload_processing(self) -> None:
@@ -1,34 +1,38 @@
1
1
  """API Implemenation for the Customizer to start and control the payload processing."""
2
2
 
3
3
  import logging
4
+ import mimetypes
4
5
  import os
5
6
  import signal
7
+ import tempfile
6
8
  from http import HTTPStatus
7
9
  from typing import Annotated
8
10
 
9
- from fastapi import APIRouter, Depends, HTTPException
10
- from fastapi.responses import JSONResponse, RedirectResponse
11
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
12
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
11
13
 
12
14
  from pyxecm.customizer.api.auth.functions import get_authorized_user
13
15
  from pyxecm.customizer.api.auth.models import User
14
- from pyxecm.customizer.api.common.functions import PAYLOAD_LIST
16
+ from pyxecm.customizer.api.common.functions import PAYLOAD_LIST, list_files_in_directory
15
17
  from pyxecm.customizer.api.common.models import CustomizerStatus
16
18
 
17
- router = APIRouter(tags=["default"])
19
+ router = APIRouter()
18
20
 
19
21
  logger = logging.getLogger("pyxecm.customizer.api.common")
20
22
 
21
23
 
22
24
  @router.get("/", include_in_schema=False)
23
- async def redirect_to_api() -> RedirectResponse:
25
+ async def redirect_to_api(request: Request) -> RedirectResponse:
24
26
  """Redirect from / to /api.
25
27
 
26
28
  Returns:
27
29
  None
28
30
 
29
31
  """
30
- return RedirectResponse(url="/api")
32
+ # Construct the new URL by appending /api
33
+ new_url = f"{request.url.path!s}api"
31
34
 
35
+ return RedirectResponse(url=new_url)
32
36
 
33
37
  @router.get(path="/status", name="Get Status")
34
38
  async def get_status() -> CustomizerStatus:
@@ -70,3 +74,48 @@ def shutdown(user: Annotated[User, Depends(get_authorized_user)]) -> JSONRespons
70
74
  os.kill(os.getpid(), signal.SIGTERM)
71
75
 
72
76
  return JSONResponse({"status": "shutdown"}, status_code=HTTPStatus.ACCEPTED)
77
+
78
+
79
+ @router.get(path="/browser_automations/assets", tags=["payload"])
80
+ def list_browser_automation_files(
81
+ user: Annotated[User, Depends(get_authorized_user)], # noqa: ARG001
82
+ ) -> JSONResponse:
83
+ """List all browser automation files."""
84
+
85
+ result = list_files_in_directory(
86
+ os.path.join(
87
+ tempfile.gettempdir(),
88
+ "browser_automations",
89
+ )
90
+ )
91
+
92
+ return JSONResponse(result)
93
+
94
+
95
+ @router.get(path="/browser_automations/download", tags=["payload"])
96
+ def get_browser_automation_file(
97
+ user: Annotated[User, Depends(get_authorized_user)], # noqa: ARG001
98
+ file: Annotated[str, Query(description="File name")],
99
+ ) -> FileResponse:
100
+ """Download the logfile for a specific payload."""
101
+
102
+ filename = os.path.join(tempfile.gettempdir(), "browser_automations", file)
103
+
104
+ if not os.path.isfile(filename):
105
+ raise HTTPException(
106
+ status_code=HTTPStatus.NOT_FOUND,
107
+ detail="File -> '{}' not found".format(filename),
108
+ )
109
+
110
+ media_type, _ = mimetypes.guess_type(filename)
111
+
112
+ with open(filename, "rb") as f:
113
+ content = f.read()
114
+
115
+ return Response(
116
+ content,
117
+ media_type=media_type,
118
+ headers={
119
+ "Content-Disposition": f'attachment; filename="{os.path.basename(filename)}"',
120
+ },
121
+ )
@@ -16,14 +16,25 @@ from pydantic_settings import (
16
16
  class CustomizerAPISettings(BaseSettings):
17
17
  """Settings for the Customizer API."""
18
18
 
19
- api_token: str | None = Field(
19
+ title: str = Field(default="Customizer API", description="Name of the API Service")
20
+ description: str = Field(
21
+ default="API provided by [pyxecm](https://github.com/opentext/pyxecm). The documentation for the payload syntax can be found [here](https://opentext.github.io/pyxecm/payload-syntax/).",
22
+ description="Descriptive text on the SwaggerUI page.",
23
+ )
24
+
25
+ api_key: str | None = Field(
20
26
  default=None,
21
- description="Optional token that can be specified that has access to the Customizer API, bypassing the OTDS authentication.",
27
+ description="Optional API KEY that can be specified that has access to the Customizer API, bypassing the OTDS authentication.",
22
28
  )
23
29
  bind_address: str = Field(default="0.0.0.0", description="Interface to bind the Customizer API.") # noqa: S104
24
30
  bind_port: int = Field(default=8000, description="Port to bind the Customizer API to")
25
31
  workers: int = Field(default=1, description="Number of workers to use for the API BackgroundTasks")
32
+ root_path: str = Field(default="/", description="Root path for the Customizer API")
33
+ openapi_url: str = Field(default="/api/openapi.json", description="OpenAPI URL")
26
34
 
35
+ concurrent_payloads: int = Field(
36
+ default=3, description="Maximum number of concurrent payloads that are executed at the same time."
37
+ )
27
38
  import_payload: bool = Field(default=False)
28
39
  payload: str = Field(
29
40
  default="/payload/payload.yml.gz.b64",
@@ -58,7 +69,7 @@ class CustomizerAPISettings(BaseSettings):
58
69
  description="Namespace to use for otxecm resource lookups",
59
70
  )
60
71
  maintenance_mode: bool = Field(
61
- default=True,
72
+ default=False,
62
73
  description="Automatically enable and disable the maintenance mode during payload deployments.",
63
74
  )
64
75