PAWpy 0.1.0__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.
@@ -0,0 +1,63 @@
1
+ """Exception hierarchy for PAWpy.
2
+
3
+ Mirrors the spirit of TM1py's exception design: a single base exception
4
+ (`PAWException`) with specialised subclasses so callers can catch broadly or
5
+ narrowly. HTTP failures carry the status code, the request method/url, and the
6
+ (possibly truncated) response body to make debugging against a remote PAW
7
+ instance practical.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+
15
+ class PAWException(Exception):
16
+ """Base class for every error raised by PAWpy."""
17
+
18
+
19
+ class PAWConfigException(PAWException):
20
+ """Raised when PAWpy is constructed with an invalid/incomplete configuration."""
21
+
22
+
23
+ class PAWAuthenticationException(PAWException):
24
+ """Raised when login / token acquisition fails."""
25
+
26
+
27
+ class PAWTimeoutException(PAWException):
28
+ """Raised when a request exceeds the configured timeout."""
29
+
30
+ def __init__(self, method: str, url: str, timeout: float):
31
+ self.method = method
32
+ self.url = url
33
+ self.timeout = timeout
34
+ super().__init__(f"Request '{method} {url}' timed out after {timeout}s")
35
+
36
+
37
+ class PAWRestException(PAWException):
38
+ """Raised when PAW returns a non-2xx HTTP status code."""
39
+
40
+ def __init__(
41
+ self,
42
+ status_code: int,
43
+ reason: str,
44
+ method: str,
45
+ url: str,
46
+ response_body: Optional[str] = None,
47
+ ):
48
+ self.status_code = status_code
49
+ self.reason = reason
50
+ self.method = method
51
+ self.url = url
52
+ self.response_body = response_body
53
+ body_preview = ""
54
+ if response_body:
55
+ body_preview = response_body if len(response_body) <= 2000 else response_body[:2000] + "…"
56
+ body_preview = f"\nResponse body: {body_preview}"
57
+ super().__init__(
58
+ f"PAW REST request failed: {method} {url} -> {status_code} {reason}{body_preview}"
59
+ )
60
+
61
+
62
+ class PAWNotFoundException(PAWRestException):
63
+ """Raised on HTTP 404 — the requested asset/resource does not exist."""
@@ -0,0 +1,17 @@
1
+ from PAWpy.Exceptions.Exceptions import (
2
+ PAWException,
3
+ PAWRestException,
4
+ PAWAuthenticationException,
5
+ PAWConfigException,
6
+ PAWNotFoundException,
7
+ PAWTimeoutException,
8
+ )
9
+
10
+ __all__ = [
11
+ "PAWException",
12
+ "PAWRestException",
13
+ "PAWAuthenticationException",
14
+ "PAWConfigException",
15
+ "PAWNotFoundException",
16
+ "PAWTimeoutException",
17
+ ]
PAWpy/Objects/Asset.py ADDED
@@ -0,0 +1,51 @@
1
+ """Asset — a node in the PAW content store (folder, book/dashboard, view…).
2
+
3
+ Maps the properties documented for the Content Services API
4
+ (``/pacontent/v1/Assets``): ``id``, ``type``, ``name``, ``path``,
5
+ ``description``, ``content``, ``custom_properties`` plus the read-only system
6
+ timestamps.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+ from PAWpy.Objects.TM1Object import PAWObject
14
+
15
+
16
+ class Asset(PAWObject):
17
+ @property
18
+ def id(self) -> Optional[str]:
19
+ return self._body.get("id")
20
+
21
+ @property
22
+ def type(self) -> Optional[str]:
23
+ return self._body.get("type")
24
+
25
+ @property
26
+ def name(self) -> Optional[str]:
27
+ return self._body.get("name")
28
+
29
+ @property
30
+ def path(self) -> Optional[str]:
31
+ return self._body.get("path")
32
+
33
+ @property
34
+ def description(self) -> Optional[str]:
35
+ return self._body.get("description")
36
+
37
+ @property
38
+ def content(self) -> Any:
39
+ return self._body.get("content")
40
+
41
+ @property
42
+ def custom_properties(self) -> Dict[str, Any]:
43
+ return self._body.get("custom_properties") or {}
44
+
45
+ @property
46
+ def is_folder(self) -> bool:
47
+ return (self._body.get("type") or "").lower() == "folder"
48
+
49
+ @property
50
+ def is_book(self) -> bool:
51
+ return (self._body.get("type") or "").lower() in ("book", "dashboard")
@@ -0,0 +1,29 @@
1
+ """Base object — a light wrapper around the raw JSON body PAW returns.
2
+
3
+ Like TM1py's object layer, every concrete object keeps the original ``body``
4
+ dict around (``raw``) so nothing the API returns is ever lost, while exposing
5
+ the common fields as typed properties.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict
11
+
12
+
13
+ class PAWObject:
14
+ def __init__(self, body: Dict[str, Any]):
15
+ self._body: Dict[str, Any] = dict(body or {})
16
+
17
+ @property
18
+ def raw(self) -> Dict[str, Any]:
19
+ """The untouched JSON dict PAW returned for this resource."""
20
+ return self._body
21
+
22
+ def get(self, key: str, default: Any = None) -> Any:
23
+ return self._body.get(key, default)
24
+
25
+ def __getitem__(self, key: str) -> Any:
26
+ return self._body[key]
27
+
28
+ def __repr__(self) -> str:
29
+ return f"{self.__class__.__name__}({self._body!r})"
@@ -0,0 +1,4 @@
1
+ from PAWpy.Objects.TM1Object import PAWObject
2
+ from PAWpy.Objects.Asset import Asset
3
+
4
+ __all__ = ["PAWObject", "Asset"]
PAWpy/PAWService.py ADDED
@@ -0,0 +1,99 @@
1
+ """PAWService — the top-level entry point (mirrors TM1py's ``TM1Service``).
2
+
3
+ Construct one ``PAWService`` with connection + auth parameters and reach every
4
+ sub-service through it::
5
+
6
+ with PAWService(host="paw.acme.com", auth_mode="oauth",
7
+ client_id="id", client_secret="secret",
8
+ token_url="https://idp/token") as paw:
9
+ books = paw.books.get_all("/shared/FP&A")
10
+ url = paw.books.get_embed_url("/shared/FP&A/Monthly Report")
11
+ servers = paw.admin.get_tm1_servers()
12
+ data = paw.tm1("Global FPA").execute_mdx("SELECT ...")
13
+ embed = paw.ui.cube_viewer_url("Global FPA", "Revenue Cube", "Monthly View")
14
+
15
+ All keyword arguments are forwarded to :class:`RestService`, which performs
16
+ authentication on construction (unless ``connect=False``).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Dict
22
+
23
+ from PAWpy.Services.AdminService import AdminService, DEFAULT_ADMIN_BASE
24
+ from PAWpy.Services.BookService import BookService
25
+ from PAWpy.Services.ContentService import ContentService, DEFAULT_CONTENT_BASE
26
+ from PAWpy.Services.RestService import RestService
27
+ from PAWpy.Services.TM1ProxyService import TM1ProxyService
28
+ from PAWpy.Services.UIService import UIService
29
+ from PAWpy.Services.ViewService import ViewService
30
+
31
+
32
+ class PAWService:
33
+ def __init__(
34
+ self,
35
+ host: str,
36
+ *,
37
+ database: str = None,
38
+ content_base: str = DEFAULT_CONTENT_BASE,
39
+ admin_base: str = DEFAULT_ADMIN_BASE,
40
+ **rest_kwargs,
41
+ ):
42
+ """
43
+ :param host: PAW hostname (no scheme), e.g. ``paw.acme.com``.
44
+ :param database: default TM1 database/server for :meth:`tm1` when called
45
+ with no argument.
46
+ :param content_base: base path of the content services API.
47
+ :param admin_base: base path of the admin API.
48
+ :param rest_kwargs: forwarded to :class:`RestService` (auth_mode,
49
+ client_id, port, ssl, verify, timeout, tenant_id, …).
50
+ """
51
+ self._default_database = database
52
+ self._rest = RestService(host=host, **rest_kwargs)
53
+
54
+ # Core services
55
+ self.content = ContentService(self._rest, content_base=content_base)
56
+ self.ui = UIService(self._rest)
57
+ self.books = BookService(self._rest, self.content, self.ui)
58
+ self.views = ViewService(self._rest, self.content, self.ui)
59
+ self.admin = AdminService(self._rest, admin_base=admin_base)
60
+
61
+ # Cache of per-database TM1 proxy services.
62
+ self._tm1_cache: Dict[str, TM1ProxyService] = {}
63
+
64
+ # ------------------------------------------------------------------ #
65
+ # TM1 proxy access
66
+ # ------------------------------------------------------------------ #
67
+ def tm1(self, database: str = None) -> TM1ProxyService:
68
+ """Return a :class:`TM1ProxyService` for *database* (or the default).
69
+
70
+ Instances are cached per database name so repeated calls are cheap.
71
+ """
72
+ db = database or self._default_database
73
+ if not db:
74
+ raise ValueError(
75
+ "No TM1 database specified and no default 'database' was set on PAWService"
76
+ )
77
+ if db not in self._tm1_cache:
78
+ self._tm1_cache[db] = TM1ProxyService(self._rest, db)
79
+ return self._tm1_cache[db]
80
+
81
+ # ------------------------------------------------------------------ #
82
+ # Lifecycle
83
+ # ------------------------------------------------------------------ #
84
+ @property
85
+ def rest(self) -> RestService:
86
+ return self._rest
87
+
88
+ @property
89
+ def base_url(self) -> str:
90
+ return self._rest.base_url
91
+
92
+ def logout(self) -> None:
93
+ self._rest.logout()
94
+
95
+ def __enter__(self) -> "PAWService":
96
+ return self
97
+
98
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
99
+ self.logout()
@@ -0,0 +1,39 @@
1
+ """AdminService — PAW administration REST surface (``/api/v1/admin``).
2
+
3
+ Covers the administrative endpoints exposed by recent PAW builds: the list of
4
+ registered TM1 / database servers, users and groups. IBM documents the broad
5
+ shape (``/api/v1/admin/...``) but the per-build resource names vary, so this
6
+ service offers typed helpers for the common resources **and** a generic
7
+ :meth:`get` escape hatch for anything not yet wrapped.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from PAWpy.Services.ObjectService import ObjectService
15
+ from PAWpy.Services.RestService import RestService
16
+ from PAWpy.Utils.Utils import odata_value_list
17
+
18
+ DEFAULT_ADMIN_BASE = "/api/v1/admin"
19
+
20
+
21
+ class AdminService(ObjectService):
22
+ def __init__(self, rest: RestService, admin_base: str = DEFAULT_ADMIN_BASE):
23
+ super().__init__(rest)
24
+ self._base = "/" + admin_base.strip("/")
25
+
26
+ def get(self, resource: str, params: Optional[Dict[str, Any]] = None) -> Any:
27
+ """Generic GET against an admin sub-resource (e.g. ``"servers"``)."""
28
+ resp = self._rest.GET(f"{self._base}/{resource.lstrip('/')}", params=params)
29
+ return resp.json()
30
+
31
+ def get_tm1_servers(self) -> List[Dict[str, Any]]:
32
+ """List the TM1 / database servers registered with this PAW instance."""
33
+ return odata_value_list(self.get("servers"))
34
+
35
+ def get_users(self) -> List[Dict[str, Any]]:
36
+ return odata_value_list(self.get("users"))
37
+
38
+ def get_groups(self) -> List[Dict[str, Any]]:
39
+ return odata_value_list(self.get("groups"))
@@ -0,0 +1,43 @@
1
+ """BookService — convenience layer over ContentService for PAW books.
2
+
3
+ A "book" is just a content asset of type ``book``/``dashboard``. This service
4
+ filters the content store down to books and pairs each with an embed URL from
5
+ :class:`UIService`, so the common "list the books in a folder and get an iframe
6
+ URL" workflow is one call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List
12
+
13
+ from PAWpy.Objects.Asset import Asset
14
+ from PAWpy.Services.ContentService import ContentService
15
+ from PAWpy.Services.ObjectService import ObjectService
16
+ from PAWpy.Services.RestService import RestService
17
+ from PAWpy.Services.UIService import UIService
18
+
19
+
20
+ class BookService(ObjectService):
21
+ def __init__(self, rest: RestService, content: ContentService, ui: UIService):
22
+ super().__init__(rest)
23
+ self._content = content
24
+ self._ui = ui
25
+
26
+ def get_all(self, folder_path: str) -> List[Asset]:
27
+ """List the books/dashboards directly under *folder_path*."""
28
+ children = self._content.list_children(folder_path)
29
+ return [a for a in children if a.is_book]
30
+
31
+ def get(self, path: str, expand_content: bool = False) -> Asset:
32
+ """Fetch a single book by content-store path."""
33
+ return self._content.get_by_path(path, expand_content=expand_content)
34
+
35
+ def get_embed_url(self, path: str, embed: bool = True) -> str:
36
+ """Build the ``/ui?type=book`` iframe URL for the book at *path*."""
37
+ return self._ui.book_url(path, embed=embed)
38
+
39
+ def exists(self, path: str) -> bool:
40
+ return self._content.exists(path)
41
+
42
+ def delete(self, path: str) -> None:
43
+ self._content.delete_by_path(path)
@@ -0,0 +1,142 @@
1
+ """ContentService — CRUD over the PAW Content Services API.
2
+
3
+ Wraps the OData-style ``/pacontent/v1/Assets`` surface documented by IBM:
4
+
5
+ GET /pacontent/v1/Assets('<id>') fetch by id
6
+ GET /pacontent/v1/Assets(path='<path>') fetch by path (encoded x2)
7
+ GET /pacontent/v1/Assets(path='<folder>')/Assets list children
8
+ POST /pacontent/v1/Assets create folder / dashboard
9
+ PUT /pacontent/v1/Assets(id='<id>', type='<t>') update content / props
10
+ DELETE /pacontent/v1/Assets(id='<id>', type='<t>') delete by id+type
11
+ DELETE /pacontent/v1/Assets(path='<path>') delete by path
12
+
13
+ Supports the OData query options ``$filter / $select / $expand / $orderby /
14
+ $top / $skip`` via :func:`PAWpy.Utils.odata_query`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from PAWpy.Objects.Asset import Asset
22
+ from PAWpy.Services.ObjectService import ObjectService
23
+ from PAWpy.Services.RestService import RestService
24
+ from PAWpy.Utils.Utils import encode_path_twice, odata_query, odata_value_list
25
+
26
+ # Default base path of the content services API. Override via the constructor
27
+ # for deployments that expose content under a different prefix.
28
+ DEFAULT_CONTENT_BASE = "/pacontent/v1"
29
+
30
+
31
+ class ContentService(ObjectService):
32
+ def __init__(self, rest: RestService, content_base: str = DEFAULT_CONTENT_BASE):
33
+ super().__init__(rest)
34
+ self._base = "/" + content_base.strip("/")
35
+
36
+ # ------------------------------------------------------------------ #
37
+ # Read
38
+ # ------------------------------------------------------------------ #
39
+ def get(self, asset_id: str, expand_content: bool = False) -> Asset:
40
+ """Fetch a single asset by id."""
41
+ params = odata_query(expand="content") if expand_content else None
42
+ resp = self._rest.GET(f"{self._base}/Assets('{asset_id}')", params=params)
43
+ return Asset(resp.json())
44
+
45
+ def get_by_path(self, path: str, expand_content: bool = False) -> Asset:
46
+ """Fetch a single asset by its content-store path (e.g. ``/shared/FP&A``)."""
47
+ encoded = encode_path_twice(path)
48
+ params = odata_query(expand="content") if expand_content else None
49
+ resp = self._rest.GET(f"{self._base}/Assets(path='{encoded}')", params=params)
50
+ return Asset(resp.json())
51
+
52
+ def list_children(
53
+ self,
54
+ folder_path: str,
55
+ *,
56
+ filter: Optional[str] = None,
57
+ select: Optional[str] = None,
58
+ expand: Optional[str] = None,
59
+ orderby: Optional[str] = None,
60
+ top: Optional[int] = None,
61
+ skip: Optional[int] = None,
62
+ ) -> List[Asset]:
63
+ """List the child assets inside the folder at *folder_path*."""
64
+ encoded = encode_path_twice(folder_path)
65
+ params = odata_query(
66
+ filter=filter, select=select, expand=expand,
67
+ orderby=orderby, top=top, skip=skip,
68
+ )
69
+ resp = self._rest.GET(
70
+ f"{self._base}/Assets(path='{encoded}')/Assets",
71
+ params=params or None,
72
+ )
73
+ return [Asset(item) for item in odata_value_list(resp.json())]
74
+
75
+ # ------------------------------------------------------------------ #
76
+ # Create
77
+ # ------------------------------------------------------------------ #
78
+ def create(
79
+ self,
80
+ type: str,
81
+ name: str,
82
+ path: str,
83
+ content: Any = None,
84
+ custom_properties: Optional[Dict[str, Any]] = None,
85
+ ) -> Asset:
86
+ """Create a new asset (``type`` = ``folder`` or ``dashboard``)."""
87
+ body: Dict[str, Any] = {"type": type, "name": name, "path": path}
88
+ if content is not None:
89
+ body["content"] = content
90
+ if custom_properties is not None:
91
+ body["custom_properties"] = custom_properties
92
+ resp = self._rest.POST(f"{self._base}/Assets", json=body)
93
+ return Asset(resp.json())
94
+
95
+ def create_folder(self, name: str, path: str, **kwargs) -> Asset:
96
+ return self.create("folder", name, path, **kwargs)
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # Update
100
+ # ------------------------------------------------------------------ #
101
+ def update(
102
+ self,
103
+ asset_id: str,
104
+ type: str,
105
+ content: Any = None,
106
+ custom_properties: Optional[Dict[str, Any]] = None,
107
+ expand_content: bool = True,
108
+ ) -> Asset:
109
+ """Update an asset's ``content`` and/or ``custom_properties``."""
110
+ body: Dict[str, Any] = {}
111
+ if content is not None:
112
+ body["content"] = content
113
+ if custom_properties is not None:
114
+ body["custom_properties"] = custom_properties
115
+ params = odata_query(expand="content") if expand_content else None
116
+ resp = self._rest.PUT(
117
+ f"{self._base}/Assets(id='{asset_id}', type='{type}')",
118
+ json=body,
119
+ params=params,
120
+ )
121
+ return Asset(resp.json())
122
+
123
+ # ------------------------------------------------------------------ #
124
+ # Delete
125
+ # ------------------------------------------------------------------ #
126
+ def delete(self, asset_id: str, type: str) -> None:
127
+ self._rest.DELETE(f"{self._base}/Assets(id='{asset_id}', type='{type}')")
128
+
129
+ def delete_by_path(self, path: str) -> None:
130
+ encoded = encode_path_twice(path)
131
+ self._rest.DELETE(f"{self._base}/Assets(path='{encoded}')")
132
+
133
+ # ------------------------------------------------------------------ #
134
+ # Convenience
135
+ # ------------------------------------------------------------------ #
136
+ def exists(self, path: str) -> bool:
137
+ from PAWpy.Exceptions.Exceptions import PAWNotFoundException
138
+ try:
139
+ self.get_by_path(path)
140
+ return True
141
+ except PAWNotFoundException:
142
+ return False
@@ -0,0 +1,14 @@
1
+ """Base for every service — holds the shared RestService handle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PAWpy.Services.RestService import RestService
6
+
7
+
8
+ class ObjectService:
9
+ def __init__(self, rest: RestService):
10
+ self._rest = rest
11
+
12
+ @property
13
+ def rest(self) -> RestService:
14
+ return self._rest