pyxecm 2.0.2__py3-none-any.whl → 2.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

pyxecm/__init__.py CHANGED
@@ -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"]
pyxecm/avts.py CHANGED
@@ -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
 
@@ -105,7 +105,8 @@ async def lifespan(
105
105
  app = FastAPI(
106
106
  docs_url="/api",
107
107
  title="Customizer API",
108
- openapi_url="/api/openapi.json",
108
+ openapi_url=api_settings.openapi_url,
109
+ root_path=api_settings.root_path,
109
110
  lifespan=lifespan,
110
111
  version=version("pyxecm"),
111
112
  openapi_tags=[
@@ -135,7 +136,6 @@ if api_settings.ws_terminal:
135
136
  if api_settings.csai:
136
137
  app.include_router(router=v1_csai_router)
137
138
 
138
-
139
139
  logger = logging.getLogger("CustomizerAPI")
140
140
  app.add_middleware(
141
141
  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:
@@ -1,10 +1,13 @@
1
1
  """Define common functions."""
2
2
 
3
3
  import logging
4
+ import os
4
5
 
5
6
  from pyxecm.customizer.api.common.payload_list import PayloadList
6
7
  from pyxecm.customizer.api.settings import CustomizerAPISettings, api_settings
7
8
  from pyxecm.customizer.k8s import K8s
9
+ from pyxecm.customizer.settings import Settings
10
+ from pyxecm.otcs import OTCS
8
11
 
9
12
  logger = logging.getLogger("pyxecm.customizer.api")
10
13
 
@@ -25,6 +28,36 @@ def get_k8s_object() -> K8s:
25
28
  return K8s(logger=logger, namespace=api_settings.namespace)
26
29
 
27
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
+
28
61
  def get_settings() -> CustomizerAPISettings:
29
62
  """Get the API Settings object.
30
63
 
@@ -45,3 +78,24 @@ def get_otcs_logs_lock() -> dict:
45
78
  """
46
79
 
47
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
@@ -1,17 +1,19 @@
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, 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
19
  router = APIRouter(tags=["default"])
@@ -70,3 +72,48 @@ def shutdown(user: Annotated[User, Depends(get_authorized_user)]) -> JSONRespons
70
72
  os.kill(os.getpid(), signal.SIGTERM)
71
73
 
72
74
  return JSONResponse({"status": "shutdown"}, status_code=HTTPStatus.ACCEPTED)
75
+
76
+
77
+ @router.get(path="/browser_automations/assets")
78
+ def list_browser_automation_files(
79
+ user: Annotated[User, Depends(get_authorized_user)], # noqa: ARG001
80
+ ) -> JSONResponse:
81
+ """List all browser automation files."""
82
+
83
+ result = list_files_in_directory(
84
+ os.path.join(
85
+ tempfile.gettempdir(),
86
+ "browser_automations",
87
+ )
88
+ )
89
+
90
+ return JSONResponse(result)
91
+
92
+
93
+ @router.get(path="/browser_automations/download")
94
+ def get_browser_automation_file(
95
+ user: Annotated[User, Depends(get_authorized_user)], # noqa: ARG001
96
+ file: Annotated[str, Query(description="File name")],
97
+ ) -> FileResponse:
98
+ """Download the logfile for a specific payload."""
99
+
100
+ filename = os.path.join(tempfile.gettempdir(), "browser_automations", file)
101
+
102
+ if not os.path.isfile(filename):
103
+ raise HTTPException(
104
+ status_code=HTTPStatus.NOT_FOUND,
105
+ detail="File -> '{}' not found".format(filename),
106
+ )
107
+
108
+ media_type, _ = mimetypes.guess_type(filename)
109
+
110
+ with open(filename, "rb") as f:
111
+ content = f.read()
112
+
113
+ return Response(
114
+ content,
115
+ media_type=media_type,
116
+ headers={
117
+ "Content-Disposition": f'attachment; filename="{os.path.basename(filename)}"',
118
+ },
119
+ )
@@ -16,13 +16,15 @@ 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
+ api_key: str | None = Field(
20
20
  default=None,
21
- description="Optional token that can be specified that has access to the Customizer API, bypassing the OTDS authentication.",
21
+ description="Optional API KEY that can be specified that has access to the Customizer API, bypassing the OTDS authentication.",
22
22
  )
23
23
  bind_address: str = Field(default="0.0.0.0", description="Interface to bind the Customizer API.") # noqa: S104
24
24
  bind_port: int = Field(default=8000, description="Port to bind the Customizer API to")
25
25
  workers: int = Field(default=1, description="Number of workers to use for the API BackgroundTasks")
26
+ root_path: str = Field(default="/", description="Root path for the Customizer API")
27
+ openapi_url: str = Field(default="/api/openapi.json", description="OpenAPI URL")
26
28
 
27
29
  import_payload: bool = Field(default=False)
28
30
  payload: str = Field(
@@ -58,7 +60,7 @@ class CustomizerAPISettings(BaseSettings):
58
60
  description="Namespace to use for otxecm resource lookups",
59
61
  )
60
62
  maintenance_mode: bool = Field(
61
- default=True,
63
+ default=False,
62
64
  description="Automatically enable and disable the maintenance mode during payload deployments.",
63
65
  )
64
66
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
+ import json
5
6
  import os
6
7
  import pty
7
8
  import signal
@@ -15,13 +16,16 @@ router = APIRouter(tags=["terminal"])
15
16
 
16
17
 
17
18
  @router.websocket("/ws/terminal")
18
- async def ws_terminal(websocket: WebSocket, pod: str = Query(...), command: str = Query(...)) -> None:
19
+ async def ws_terminal(
20
+ websocket: WebSocket, pod: str = Query(...), command: str = Query(...), container: str = Query(None)
21
+ ) -> None:
19
22
  """Websocket to connect to a shell session in a pod.
20
23
 
21
24
  Args:
22
25
  websocket (WebSocket): WebSocket to connect to the shell session.
23
26
  pod (str): pod name to connect to.
24
27
  command (str): command to be executed.
28
+ container (str, optional): container name to connect to.
25
29
 
26
30
  """
27
31
  await websocket.accept()
@@ -30,7 +34,7 @@ async def ws_terminal(websocket: WebSocket, pod: str = Query(...), command: str
30
34
  # Wait for the first message to be the token
31
35
  token = await websocket.receive_text()
32
36
 
33
- user = await get_current_user(token)
37
+ user = await get_current_user(token=token, api_key=token)
34
38
  authrorized = await get_authorized_user(user)
35
39
 
36
40
  if not authrorized:
@@ -45,11 +49,17 @@ async def ws_terminal(websocket: WebSocket, pod: str = Query(...), command: str
45
49
  except WebSocketDisconnect:
46
50
  return
47
51
 
48
- process = ["bash"] if pod == "customizer" else ["kubectl", "exec", "-it", pod, "--", command]
52
+ container = ["-c", container] if container else []
53
+
54
+ process = [command] if pod == "customizer" else ["kubectl", "exec", "-it", pod, *container, "--", command]
49
55
 
50
56
  pid, fd = pty.fork()
57
+ import fcntl
58
+ import struct
59
+ import termios
60
+
51
61
  if pid == 0:
52
- subprocess.run(process, check=False) # noqa: ASYNC221
62
+ process = subprocess.run(process, check=False) # noqa: ASYNC221
53
63
 
54
64
  async def read_from_pty() -> None:
55
65
  loop = asyncio.get_event_loop()
@@ -64,24 +74,39 @@ async def ws_terminal(websocket: WebSocket, pod: str = Query(...), command: str
64
74
  try:
65
75
  while True:
66
76
  data = await websocket.receive_text()
67
- os.write(fd, data.encode())
77
+ if data.startswith("{") and data.endswith("}"):
78
+ message = json.loads(data)
79
+ if message.get("type") == "resize":
80
+ rows = message.get("rows")
81
+ cols = message.get("cols")
82
+
83
+ packed_size = struct.pack("HHHH", rows, cols, 0, 0)
84
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, packed_size)
85
+
86
+ else:
87
+ os.write(fd, data.encode())
68
88
  except Exception: # noqa: S110
69
89
  pass
70
90
 
71
- # Launch read/write tasks
72
- read_task = asyncio.create_task(read_from_pty())
73
- write_task = asyncio.create_task(write_to_pty())
91
+ try:
92
+ # Launch read/write tasks
93
+ read_task = asyncio.create_task(read_from_pty())
94
+ write_task = asyncio.create_task(write_to_pty())
95
+
96
+ done, pending = await asyncio.wait([read_task, write_task], return_when=asyncio.FIRST_COMPLETED)
74
97
 
75
- done, pending = await asyncio.wait([read_task, write_task], return_when=asyncio.FIRST_COMPLETED)
98
+ # Cancel other task
99
+ for task in pending:
100
+ task.cancel()
101
+ finally:
102
+ with contextlib.suppress(Exception):
103
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
76
104
 
77
- # Cancel other task
78
- for task in pending:
79
- task.cancel()
105
+ with contextlib.suppress(Exception):
106
+ process.terminate()
80
107
 
81
- try: # noqa: SIM105
82
- os.kill(pid, signal.SIGKILL)
83
- except ProcessLookupError:
84
- pass # Already exited
108
+ with contextlib.suppress(Exception):
109
+ os.kill(pid, signal.SIGKILL)
85
110
 
86
- with contextlib.suppress(Exception):
87
- os.close(fd)
111
+ with contextlib.suppress(Exception):
112
+ os.close(fd)
@@ -0,0 +1,18 @@
1
+ """Define Models for Payload."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class CSAIEmbedMetadata(BaseModel):
7
+ """Defines Data Model for embeding metadata for documents and workspaces."""
8
+
9
+ node_id: int = None
10
+ crawl: bool = False
11
+ wait_for_completion: bool = False
12
+ message_override: dict = None
13
+ timeout: float = 30.0
14
+ document_metadata: bool = False
15
+ images: bool = False
16
+ image_prompt: str = ""
17
+ workspace_metadata: bool = True
18
+ remove_existing: bool = False
@@ -9,15 +9,40 @@ from fastapi.responses import JSONResponse
9
9
 
10
10
  from pyxecm.customizer.api.auth.functions import get_authorized_user
11
11
  from pyxecm.customizer.api.auth.models import User
12
- from pyxecm.customizer.api.common.functions import get_k8s_object, get_settings
12
+ from pyxecm.customizer.api.common.functions import get_k8s_object, get_otcs_object, get_settings
13
13
  from pyxecm.customizer.api.settings import CustomizerAPISettings
14
+ from pyxecm.customizer.api.v1_csai.models import CSAIEmbedMetadata
14
15
  from pyxecm.customizer.k8s import K8s
16
+ from pyxecm.otcs import OTCS
15
17
 
16
18
  router = APIRouter(prefix="/api/v1/csai", tags=["csai"])
17
19
 
18
20
  logger = logging.getLogger("pyxecm.customizer.api.v1_csai")
19
21
 
20
22
 
23
+ @router.post("/metadata")
24
+ def embed_metadata(
25
+ user: Annotated[User, Depends(get_authorized_user)], # noqa: ARG001
26
+ otcs_object: Annotated[OTCS, Depends(get_otcs_object)],
27
+ body: Annotated[CSAIEmbedMetadata, Body()],
28
+ ) -> JSONResponse:
29
+ """Embed the Metadata of the given objects.
30
+
31
+ Args:
32
+ user (Annotated[User, Depends): User required for authentication
33
+ otcs_object (Annotated[OTCS, Depends(get_otcs_object)]): OTCS object to interact with OTCS
34
+ body (Annotated[CSAIEmbedMetadata, Body): Request body
35
+
36
+ Returns:
37
+ JSONResponse: JSONResponse with success=true/false
38
+
39
+ """
40
+
41
+ success = otcs_object.aviator_embed_metadata(**body.model_dump())
42
+
43
+ return JSONResponse({"success": success})
44
+
45
+
21
46
  @router.get("")
22
47
  def get_csai_config_data(
23
48
  user: Annotated[User, Depends(get_authorized_user)],
@@ -129,16 +129,22 @@ def import_payload(
129
129
  payload_dir = payload
130
130
 
131
131
  if payload_dir is None:
132
- import_payload_file(payload, enabled, dependencies)
132
+ try:
133
+ import_payload_file(payload, enabled, dependencies)
134
+ except PayloadImportError as exc:
135
+ logger.error(exc)
136
+ logger.debug(exc, exc_info=True)
133
137
  return
138
+
134
139
  elif not os.path.isdir(payload_dir):
135
140
  return
136
141
 
137
142
  for filename in sorted(os.listdir(payload_dir)):
138
143
  try:
139
144
  import_payload_file(os.path.join(payload_dir, filename), enabled, dependencies)
140
- except PayloadImportError:
141
- logger.error("Payload import failed")
145
+ except PayloadImportError as exc:
146
+ logger.error(exc)
147
+ logger.debug(exc, exc_info=True)
142
148
 
143
149
 
144
150
  def prepare_dependencies(dependencies: list) -> list | None: