Skip to content

Commit 12465ba

Browse files
authored
feat(print): add print module (#149)
1 parent 98a02b1 commit 12465ba

20 files changed

Lines changed: 1789 additions & 7 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi
2424
- **Secret Resolver**
2525
- **Telemetry & Observability**
2626
- **Data Anonymization Service**
27+
- **Print Service**
2728

2829
## Requirements and Setup
2930

@@ -77,6 +78,7 @@ Each module has comprehensive usage guides:
7778
- [ObjectStore](src/sap_cloud_sdk/objectstore/user-guide.md)
7879
- [Secret Resolver](src/sap_cloud_sdk/core/secret_resolver/user-guide.md)
7980
- [Telemetry](src/sap_cloud_sdk/core/telemetry/user-guide.md)
81+
- [Print](src/sap_cloud_sdk/print/user-guide.md)
8082
- [Data Anonymization](src/sap_cloud_sdk/core/data_anonymization/user-guide.md)
8183

8284
## Support, Feedback, Contributing

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.26.1"
3+
version = "0.27.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"

src/sap_cloud_sdk/core/telemetry/module.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Module(str, Enum):
1717
DMS = "dms"
1818
EXTENSIBILITY = "extensibility"
1919
OBJECTSTORE = "objectstore"
20+
PRINT = "print"
2021
TELEMETRY = "telemetry"
2122

2223
def __str__(self) -> str:

src/sap_cloud_sdk/core/telemetry/operation.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ class Operation(str, Enum):
142142
AICORE_SET_CONFIG = "set_aicore_config"
143143
AICORE_AUTO_INSTRUMENT = "auto_instrument"
144144

145+
# Print Operations
146+
PRINT_LIST_QUEUES = "list_queues"
147+
PRINT_CREATE_QUEUE = "create_queue"
148+
PRINT_GET_PROFILES = "get_print_profiles"
149+
PRINT_UPLOAD_DOCUMENT = "upload_document"
150+
PRINT_CREATE_TASK = "create_print_task"
151+
PRINT_CREATE_CLIENT = "print_create_client"
152+
145153
# DMS Operations
146154
DMS_ONBOARD_REPOSITORY = "onboard_repository"
147155
DMS_GET_REPOSITORY = "get_repository"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""SAP Cloud SDK for Python - Print module
2+
3+
The create_client() function loads credentials from mounts/env vars and
4+
returns a configured PrintClient.
5+
6+
Usage:
7+
from sap_cloud_sdk.print import create_client, PrintQueue, PrintContent, PrintTask
8+
9+
client = create_client()
10+
11+
# List queues
12+
queues = client.list_queues()
13+
14+
# Upload a document and print it
15+
with open("invoice.pdf", "rb") as f:
16+
doc_id = client.upload_document(f, filename="invoice.pdf")
17+
18+
task = PrintTask(
19+
item_id=doc_id,
20+
qname="my-queue",
21+
print_contents=[PrintContent(object_key=doc_id, document_name="invoice.pdf")],
22+
)
23+
client.create_print_task(task)
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from typing import Optional
29+
30+
from sap_cloud_sdk.print._models import (
31+
PrintContent,
32+
PrintProfile,
33+
PrintQueue,
34+
PrintTask,
35+
PrintTaskMetadata,
36+
)
37+
from sap_cloud_sdk.print.config import load_from_env_or_mount, PrintConfig
38+
from sap_cloud_sdk.print._http import PrintHttp, TokenProvider
39+
from sap_cloud_sdk.print.client import PrintClient
40+
from sap_cloud_sdk.print.exceptions import (
41+
PrintError,
42+
ClientCreationError,
43+
ConfigError,
44+
HttpError,
45+
PrintOperationError,
46+
)
47+
48+
from sap_cloud_sdk.core.telemetry import (
49+
Module,
50+
Operation,
51+
record_error_metric as _record_error_metric,
52+
)
53+
54+
55+
def create_client(
56+
*,
57+
instance: Optional[str] = None,
58+
config: Optional[PrintConfig] = None,
59+
_telemetry_source: Optional[Module] = None,
60+
) -> PrintClient:
61+
"""Create a PrintClient with secret resolution and OAuth setup.
62+
63+
Args:
64+
instance: Instance name used for secret resolution. Defaults to "default".
65+
config: Optional explicit PrintConfig, bypasses secret resolution.
66+
_telemetry_source: Internal parameter for telemetry. Not for external use.
67+
68+
Returns:
69+
Configured PrintClient.
70+
71+
Raises:
72+
ClientCreationError: If client creation fails.
73+
"""
74+
try:
75+
binding = config or load_from_env_or_mount(instance)
76+
tp = TokenProvider(binding)
77+
http = PrintHttp(config=binding, token_provider=tp)
78+
return PrintClient(http, _telemetry_source=_telemetry_source)
79+
except Exception as e:
80+
_record_error_metric(
81+
Module.PRINT,
82+
_telemetry_source,
83+
Operation.PRINT_CREATE_CLIENT,
84+
)
85+
raise ClientCreationError(f"failed to create print client: {e}") from e
86+
87+
88+
__all__ = [
89+
# Models
90+
"PrintQueue",
91+
"PrintProfile",
92+
"PrintContent",
93+
"PrintTask",
94+
"PrintTaskMetadata",
95+
"PrintConfig",
96+
# Factory
97+
"create_client",
98+
# Client
99+
"PrintClient",
100+
# Exceptions
101+
"PrintError",
102+
"ClientCreationError",
103+
"ConfigError",
104+
"HttpError",
105+
"PrintOperationError",
106+
]

src/sap_cloud_sdk/print/_http.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""HTTP transport and OAuth utilities for SAP Print Service."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
import json
7+
import logging
8+
from typing import Any, Dict, Optional, Protocol
9+
10+
import requests
11+
from requests import Response
12+
from requests.exceptions import RequestException
13+
from oauthlib.oauth2 import BackendApplicationClient
14+
from requests_oauthlib import OAuth2Session
15+
16+
from sap_cloud_sdk.print.config import PrintConfig
17+
from sap_cloud_sdk.print.exceptions import HttpError
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class AbstractTokenProvider(Protocol):
23+
"""Protocol for token providers — allows injection of mock providers in tests."""
24+
25+
def get_token(self) -> str: ...
26+
def resolve_username(self) -> str: ...
27+
28+
29+
class TokenProvider:
30+
"""Provides OAuth2 access tokens via client credentials flow."""
31+
32+
def __init__(self, config: PrintConfig) -> None:
33+
self._config = config
34+
client = BackendApplicationClient(client_id=config.client_id)
35+
self._session = OAuth2Session(client=client)
36+
self._cached_token: Optional[str] = None
37+
38+
def get_token(self) -> str:
39+
"""Return a valid bearer token for the Print Service.
40+
41+
Returns:
42+
A non-empty OAuth2 access token string.
43+
44+
Raises:
45+
HttpError: If the token response is missing an access_token or
46+
token acquisition fails.
47+
"""
48+
49+
try:
50+
token: Dict[str, Any] = self._session.fetch_token(
51+
token_url=self._config.token_url,
52+
client_id=self._config.client_id,
53+
client_secret=self._config.client_secret,
54+
include_client_id=True,
55+
)
56+
except Exception as e:
57+
logger.error("failed to acquire token: %s", e)
58+
raise HttpError(f"failed to acquire token: {e}") from e
59+
access_token = token.get("access_token")
60+
if not access_token:
61+
raise HttpError("token response missing access_token")
62+
self._cached_token = str(access_token)
63+
return self._cached_token
64+
65+
def resolve_username(self) -> str:
66+
"""Resolve a username from the current access token claims.
67+
68+
Returns the ``user_name`` JWT claim when present (interactive user
69+
flows), otherwise falls back to ``client_id`` (client-credentials /
70+
technical-user flows).
71+
"""
72+
token = self._cached_token or self.get_token()
73+
try:
74+
payload_b64 = token.split(".")[1]
75+
# JWT base64 uses URL-safe alphabet without padding
76+
padding = 4 - len(payload_b64) % 4
77+
if padding != 4:
78+
payload_b64 += "=" * padding
79+
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
80+
return str(
81+
claims.get("user_name")
82+
or claims.get("client_id")
83+
or self._config.client_id
84+
)
85+
except Exception:
86+
logger.debug("could not decode JWT claims, falling back to client_id")
87+
return self._config.client_id
88+
89+
90+
class PrintHttp:
91+
"""HTTP client for SAP Print Service."""
92+
93+
def __init__(
94+
self,
95+
config: PrintConfig,
96+
token_provider: AbstractTokenProvider,
97+
session: Optional[requests.Session] = None,
98+
) -> None:
99+
self._config = config
100+
self._token_provider = token_provider
101+
self._session = session or requests.Session()
102+
self._base_url = config.url.rstrip("/")
103+
104+
def get_username(self) -> str:
105+
"""Resolve the username from the current OAuth token (or fall back to client_id)."""
106+
return self._token_provider.resolve_username()
107+
108+
def _auth_headers(self) -> Dict[str, str]:
109+
token = self._token_provider.get_token()
110+
return {"Authorization": f"Bearer {token}"}
111+
112+
def _request(
113+
self,
114+
method: str,
115+
path: str,
116+
*,
117+
params: Optional[Dict[str, Any]] = None,
118+
json: Optional[Any] = None,
119+
data: Optional[Any] = None,
120+
files: Optional[Any] = None,
121+
extra_headers: Optional[Dict[str, str]] = None,
122+
) -> Response:
123+
url = f"{self._base_url}/{path.lstrip('/')}"
124+
headers = self._auth_headers()
125+
if extra_headers:
126+
headers.update(extra_headers)
127+
128+
try:
129+
resp = self._session.request(
130+
method=method,
131+
url=url,
132+
headers=headers,
133+
params=params,
134+
json=json,
135+
data=data,
136+
files=files,
137+
)
138+
except RequestException as e:
139+
logger.error("request failed [%s %s]: %s", method, url, e)
140+
raise HttpError(f"request failed: {e}") from e
141+
142+
if 200 <= resp.status_code < 300:
143+
return resp
144+
145+
text: str = ""
146+
try:
147+
text = resp.text
148+
except Exception:
149+
text = "<failed to read response body>"
150+
151+
raise HttpError(
152+
f"HTTP {resp.status_code} for {method} {url}",
153+
status_code=resp.status_code,
154+
response_text=text,
155+
)
156+
157+
def get(
158+
self,
159+
path: str,
160+
*,
161+
params: Optional[Dict[str, Any]] = None,
162+
headers: Optional[Dict[str, str]] = None,
163+
) -> Response:
164+
return self._request("GET", path, params=params, extra_headers=headers)
165+
166+
def put(
167+
self,
168+
path: str,
169+
*,
170+
json: Optional[Any] = None,
171+
headers: Optional[Dict[str, str]] = None,
172+
) -> Response:
173+
return self._request("PUT", path, json=json, extra_headers=headers)
174+
175+
def post(
176+
self,
177+
path: str,
178+
*,
179+
json: Optional[Any] = None,
180+
data: Optional[Any] = None,
181+
files: Optional[Any] = None,
182+
headers: Optional[Dict[str, str]] = None,
183+
) -> Response:
184+
return self._request(
185+
"POST", path, json=json, data=data, files=files, extra_headers=headers
186+
)

0 commit comments

Comments
 (0)