protegrity-ai-developer-python 1.2.1__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.
- appython/__init__.py +12 -0
- appython/protector.py +554 -0
- appython/service/auth_provider.py +273 -0
- appython/service/auth_token_provider.py +45 -0
- appython/service/config.py +209 -0
- appython/service/payload_builder.py +141 -0
- appython/service/request_handler.py +115 -0
- appython/service/response_handler.py +78 -0
- appython/stats/__init__.py +3 -0
- appython/stats/collector.py +90 -0
- appython/stats/writer.py +185 -0
- appython/utils/codec_helper.py +86 -0
- appython/utils/constants.py +246 -0
- appython/utils/exceptions.py +141 -0
- appython/utils/input_preprocessor.py +325 -0
- appython/utils/output_postprocessor.py +99 -0
- protegrity_ai_developer_python-1.2.1.dist-info/METADATA +428 -0
- protegrity_ai_developer_python-1.2.1.dist-info/RECORD +53 -0
- protegrity_ai_developer_python-1.2.1.dist-info/WHEEL +5 -0
- protegrity_ai_developer_python-1.2.1.dist-info/entry_points.txt +2 -0
- protegrity_ai_developer_python-1.2.1.dist-info/licenses/LICENSE +21 -0
- protegrity_ai_developer_python-1.2.1.dist-info/top_level.txt +3 -0
- protegrity_developer_python/__init__.py +4 -0
- protegrity_developer_python/scan.py +37 -0
- protegrity_developer_python/securefind.py +83 -0
- protegrity_developer_python/utils/ccn_processing.py +59 -0
- protegrity_developer_python/utils/config.py +60 -0
- protegrity_developer_python/utils/constants.py +123 -0
- protegrity_developer_python/utils/discover.py +49 -0
- protegrity_developer_python/utils/logger.py +23 -0
- protegrity_developer_python/utils/pii_processing.py +291 -0
- protegrity_developer_python/utils/protector.py +23 -0
- protegrity_developer_python/utils/semantic_guardrails.py +240 -0
- protegrity_developer_python/utils/transform.py +66 -0
- pty_migrate/__init__.py +1 -0
- pty_migrate/check_cmd.py +871 -0
- pty_migrate/cli.py +93 -0
- pty_migrate/config.py +127 -0
- pty_migrate/create_policy_cmd.py +795 -0
- pty_migrate/payloads/__init__.py +51 -0
- pty_migrate/payloads/alphabets.json +42 -0
- pty_migrate/payloads/dataelements.json +342 -0
- pty_migrate/payloads/datastores.json +7 -0
- pty_migrate/payloads/deploy_policy_ta.json +1 -0
- pty_migrate/payloads/masks.json +18 -0
- pty_migrate/payloads/members.json +62 -0
- pty_migrate/payloads/policies.json +13 -0
- pty_migrate/payloads/roles.json +32 -0
- pty_migrate/payloads/rules.json +1639 -0
- pty_migrate/payloads/sources.json +10 -0
- pty_migrate/payloads/trusted_apps.json +8 -0
- pty_migrate/ppc_client.py +371 -0
- pty_migrate/stats_cmd.py +87 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the RequestHandler class, which is responsible for sending HTTP requests to a specified API
|
|
3
|
+
endpoint using the `requests` library. It includes methods to send POST requests with JSON payloads and handle
|
|
4
|
+
the necessary headers for authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from requests.adapters import HTTPAdapter
|
|
9
|
+
from urllib3.util.retry import Retry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DEFAULT_TIMEOUT = 30
|
|
13
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
# One Session per (max_retries, backoff) combination so a connection pool can be
|
|
16
|
+
# reused across calls without re-mounting adapters on every request.
|
|
17
|
+
_session_cache: dict = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_session(max_retries: int) -> requests.Session:
|
|
21
|
+
sess = _session_cache.get(max_retries)
|
|
22
|
+
if sess is not None:
|
|
23
|
+
return sess
|
|
24
|
+
sess = requests.Session()
|
|
25
|
+
if max_retries > 0:
|
|
26
|
+
retry = Retry(
|
|
27
|
+
total=max_retries,
|
|
28
|
+
connect=max_retries,
|
|
29
|
+
read=max_retries,
|
|
30
|
+
status=max_retries,
|
|
31
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
32
|
+
allowed_methods=frozenset(["GET", "POST", "PUT", "DELETE"]),
|
|
33
|
+
backoff_factor=0.5, # 0.5s, 1s, 2s, 4s, ...
|
|
34
|
+
respect_retry_after_header=True,
|
|
35
|
+
raise_on_status=False,
|
|
36
|
+
)
|
|
37
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
38
|
+
sess.mount("https://", adapter)
|
|
39
|
+
sess.mount("http://", adapter)
|
|
40
|
+
_session_cache[max_retries] = sess
|
|
41
|
+
return sess
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_resilience(config):
|
|
45
|
+
"""Pull timeout (seconds) and max_retries from an SDK config dict."""
|
|
46
|
+
if not config:
|
|
47
|
+
return _DEFAULT_TIMEOUT, _DEFAULT_MAX_RETRIES
|
|
48
|
+
try:
|
|
49
|
+
timeout = int(config.get("request_timeout", _DEFAULT_TIMEOUT))
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
timeout = _DEFAULT_TIMEOUT
|
|
52
|
+
try:
|
|
53
|
+
retries = int(config.get("max_retries", _DEFAULT_MAX_RETRIES))
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
retries = _DEFAULT_MAX_RETRIES
|
|
56
|
+
return max(1, timeout), max(0, retries)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RequestHandler:
|
|
60
|
+
def send_api_request(
|
|
61
|
+
payload: dict, base_url: str, api_key: str, jwt_token: str
|
|
62
|
+
) -> requests.Response:
|
|
63
|
+
"""
|
|
64
|
+
Sends a POST request to the specified API endpoint with a JSON payload.
|
|
65
|
+
Legacy method retained for backward compatibility.
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
payload (dict): The JSON-serializable payload to send in the request body.
|
|
69
|
+
base_url (str): The full URL of the API endpoint to which the request is sent.
|
|
70
|
+
api_key (str): API key for x-api-key header.
|
|
71
|
+
jwt_token (str): JWT token for Authorization header.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Response: The HTTP response object returned by the `requests.post` call.
|
|
75
|
+
"""
|
|
76
|
+
headers = {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"x-api-key": api_key,
|
|
79
|
+
"Authorization": jwt_token,
|
|
80
|
+
}
|
|
81
|
+
# Legacy path: apply default timeout but no retries (preserves caller
|
|
82
|
+
# behavior for the older DE auth flow).
|
|
83
|
+
response = requests.post(
|
|
84
|
+
base_url, json=payload, headers=headers, timeout=_DEFAULT_TIMEOUT
|
|
85
|
+
)
|
|
86
|
+
return response
|
|
87
|
+
|
|
88
|
+
def send_request(
|
|
89
|
+
payload: dict, url: str, auth_provider, config: dict = None
|
|
90
|
+
) -> requests.Response:
|
|
91
|
+
"""
|
|
92
|
+
Sends a POST request using the pluggable auth provider for authentication.
|
|
93
|
+
|
|
94
|
+
Parameters:
|
|
95
|
+
payload (dict): The JSON-serializable payload to send in the request body.
|
|
96
|
+
url (str): The full URL of the API endpoint.
|
|
97
|
+
auth_provider: An AuthProvider instance that signs the request.
|
|
98
|
+
config (dict, optional): SDK config dict. Honors keys:
|
|
99
|
+
- "request_timeout" (seconds, default 30) — per-attempt HTTP timeout.
|
|
100
|
+
- "max_retries" (int, default 3) — retries on connection errors and
|
|
101
|
+
HTTP 429/5xx with exponential backoff and jitter. 0 disables retries.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Response: The HTTP response object returned by the `requests.post` call.
|
|
105
|
+
"""
|
|
106
|
+
timeout, max_retries = _resolve_resilience(config)
|
|
107
|
+
headers = {"Content-Type": "application/json"}
|
|
108
|
+
headers = auth_provider.authenticate_request("POST", url, headers, payload)
|
|
109
|
+
kwargs = auth_provider.get_request_kwargs()
|
|
110
|
+
session = _get_session(max_retries)
|
|
111
|
+
response = session.post(
|
|
112
|
+
url, json=payload, headers=headers, timeout=timeout, **kwargs
|
|
113
|
+
)
|
|
114
|
+
return response
|
|
115
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the ResponseHandler class, which processes HTTP responses from the API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from appython.utils.output_postprocessor import OutputProcessor
|
|
6
|
+
from appython.utils.constants import (
|
|
7
|
+
RETURN_CODE as return_code,
|
|
8
|
+
LOG_RETURN_CODE_UNSUPPORTED as log_return_code
|
|
9
|
+
)
|
|
10
|
+
from appython.utils.exceptions import PythonSDKException
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResponseHandler:
|
|
14
|
+
@staticmethod
|
|
15
|
+
def process(response, return_type: dict, operation_type: str):
|
|
16
|
+
"""
|
|
17
|
+
Processes the HTTP response from the API and restores the original data type(s) of the result.
|
|
18
|
+
|
|
19
|
+
This method handles both successful and error responses. For successful responses (HTTP 200),
|
|
20
|
+
it checks the return code inside the response payload and uses the OutputProcessor to convert
|
|
21
|
+
the returned data into its original type(s) based on the `return_type` metadata.
|
|
22
|
+
|
|
23
|
+
Parameters:
|
|
24
|
+
response (requests.Response): The HTTP response object returned by the API call.
|
|
25
|
+
return_type (dict): Metadata describing how to interpret the response data. Keys include:
|
|
26
|
+
- "is_bulk" (bool): Whether the response contains a list of values.
|
|
27
|
+
- "response_type" (type): The expected type of the response values.
|
|
28
|
+
- "type" (type): The container type for the result (e.g., list, tuple).
|
|
29
|
+
- "isENC" (bool): Whether the data is base64-encoded.
|
|
30
|
+
- "charset" (Charset, optional): Character encoding used for decoding bytes.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The processed result, either as a single value or a collection (list or tuple),
|
|
34
|
+
along with a tuple of return codes if bulk.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
Exception: If the return code indicates failure, if the response is malformed,
|
|
38
|
+
or if the data cannot be decoded or restored properly.
|
|
39
|
+
"""
|
|
40
|
+
if response.status_code == 200:
|
|
41
|
+
response_json = response.json()
|
|
42
|
+
is_query_success = response_json.get("success")
|
|
43
|
+
|
|
44
|
+
if not is_query_success:
|
|
45
|
+
try:
|
|
46
|
+
message = response.json().get("error_msg", "Unknown Error")
|
|
47
|
+
mapped_message = PythonSDKException.map_error_message(message)
|
|
48
|
+
except Exception:
|
|
49
|
+
mapped_message = "Failed to parse error message from response"
|
|
50
|
+
raise Exception(mapped_message)
|
|
51
|
+
|
|
52
|
+
data = response_json.get("results")
|
|
53
|
+
try:
|
|
54
|
+
result = OutputProcessor.restore_original_type(data, return_type)
|
|
55
|
+
|
|
56
|
+
if not return_type.get("is_bulk", False):
|
|
57
|
+
return result
|
|
58
|
+
else:
|
|
59
|
+
return_type_cls = return_type.get("type", list)
|
|
60
|
+
if return_type_cls == tuple:
|
|
61
|
+
return tuple(result), (return_code[operation_type],) * len(data)
|
|
62
|
+
else:
|
|
63
|
+
return result, (return_code[operation_type],) * len(data)
|
|
64
|
+
except Exception:
|
|
65
|
+
raise Exception(f"26, {log_return_code[26]}") # Error in setting data
|
|
66
|
+
else:
|
|
67
|
+
is_query_success = response.json().get("success", "Unknown Error")
|
|
68
|
+
if not is_query_success:
|
|
69
|
+
try:
|
|
70
|
+
message = response.json().get("error_msg", "Unknown Error")
|
|
71
|
+
mapped_message = PythonSDKException.map_error_message(message)
|
|
72
|
+
except Exception:
|
|
73
|
+
mapped_message = "Failed to parse error message from response"
|
|
74
|
+
raise Exception(mapped_message)
|
|
75
|
+
|
|
76
|
+
message = response.json().get("message", "Could not process the request")
|
|
77
|
+
raise Exception(message)
|
|
78
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory usage statistics collector.
|
|
3
|
+
|
|
4
|
+
Accumulates operation counts during a session and flushes to disk on close.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _stats_enabled():
|
|
12
|
+
"""Check if stats collection is enabled.
|
|
13
|
+
|
|
14
|
+
Stats are only collected when DEV_EDITION_* env vars are set (i.e. Developer Edition).
|
|
15
|
+
Can be explicitly overridden with PTY_STATS=true/false.
|
|
16
|
+
"""
|
|
17
|
+
explicit = os.getenv("PTY_STATS")
|
|
18
|
+
if explicit is not None:
|
|
19
|
+
return explicit.lower() not in ("false", "0", "no", "off")
|
|
20
|
+
# Default: enabled only when Developer Edition env vars are present
|
|
21
|
+
return any(k.startswith("DEV_EDITION_") for k in os.environ)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UsageCollector:
|
|
25
|
+
"""Collects usage statistics in memory during a session.
|
|
26
|
+
|
|
27
|
+
Stats are accumulated per data element and policy user, then flushed
|
|
28
|
+
to disk when flush() is called (typically on session close).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, user):
|
|
32
|
+
self._enabled = _stats_enabled()
|
|
33
|
+
self._user = user
|
|
34
|
+
self._data_elements = {}
|
|
35
|
+
self._session_started = date.today().isoformat()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def enabled(self):
|
|
39
|
+
return self._enabled
|
|
40
|
+
|
|
41
|
+
def record_protect(self, data_element):
|
|
42
|
+
"""Record a protect operation for a data element."""
|
|
43
|
+
if not self._enabled:
|
|
44
|
+
return
|
|
45
|
+
self._ensure_element(data_element)
|
|
46
|
+
self._data_elements[data_element]["protect_count"] += 1
|
|
47
|
+
self._data_elements[data_element]["last_used"] = date.today().isoformat()
|
|
48
|
+
|
|
49
|
+
def record_unprotect(self, data_element):
|
|
50
|
+
"""Record an unprotect operation for a data element."""
|
|
51
|
+
if not self._enabled:
|
|
52
|
+
return
|
|
53
|
+
self._ensure_element(data_element)
|
|
54
|
+
self._data_elements[data_element]["unprotect_count"] += 1
|
|
55
|
+
self._data_elements[data_element]["last_used"] = date.today().isoformat()
|
|
56
|
+
|
|
57
|
+
def record_reprotect(self, source_de, target_de):
|
|
58
|
+
"""Record a reprotect operation for source and target data elements."""
|
|
59
|
+
if not self._enabled:
|
|
60
|
+
return
|
|
61
|
+
self._ensure_element(source_de)
|
|
62
|
+
self._ensure_element(target_de)
|
|
63
|
+
self._data_elements[source_de]["reprotect_source_count"] += 1
|
|
64
|
+
self._data_elements[source_de]["last_used"] = date.today().isoformat()
|
|
65
|
+
self._data_elements[target_de]["reprotect_target_count"] += 1
|
|
66
|
+
self._data_elements[target_de]["last_used"] = date.today().isoformat()
|
|
67
|
+
|
|
68
|
+
def get_session_data(self):
|
|
69
|
+
"""Return collected stats for this session.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
dict: Session stats with user, data_elements, and metadata.
|
|
73
|
+
"""
|
|
74
|
+
return {
|
|
75
|
+
"user": self._user,
|
|
76
|
+
"data_elements": dict(self._data_elements),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def _ensure_element(self, data_element):
|
|
80
|
+
"""Ensure a data element entry exists in the collection."""
|
|
81
|
+
if data_element not in self._data_elements:
|
|
82
|
+
today = date.today().isoformat()
|
|
83
|
+
self._data_elements[data_element] = {
|
|
84
|
+
"protect_count": 0,
|
|
85
|
+
"unprotect_count": 0,
|
|
86
|
+
"reprotect_source_count": 0,
|
|
87
|
+
"reprotect_target_count": 0,
|
|
88
|
+
"first_used": today,
|
|
89
|
+
"last_used": today,
|
|
90
|
+
}
|
appython/stats/writer.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent storage for usage statistics.
|
|
3
|
+
|
|
4
|
+
Reads, merges, and writes the JSON stats file with file locking
|
|
5
|
+
for multi-process safety.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_SCHEMA_VERSION = "1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Cross-platform exclusive file locking. fcntl.flock is unix-only; Windows
|
|
21
|
+
# CPython (including Git Bash / MINGW which runs the Windows interpreter)
|
|
22
|
+
# does not ship it. msvcrt.locking provides equivalent semantics there.
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
import msvcrt
|
|
25
|
+
|
|
26
|
+
def _lock_exclusive(fd):
|
|
27
|
+
# msvcrt requires a nonzero byte count. Lock 1 byte from offset 0;
|
|
28
|
+
# this is sufficient as an advisory whole-file lock for our use.
|
|
29
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
30
|
+
while True:
|
|
31
|
+
try:
|
|
32
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
33
|
+
return
|
|
34
|
+
except OSError:
|
|
35
|
+
# LK_LOCK blocks ~10s then raises; retry until acquired.
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
def _unlock(fd):
|
|
39
|
+
try:
|
|
40
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
41
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
else:
|
|
45
|
+
import fcntl
|
|
46
|
+
|
|
47
|
+
def _lock_exclusive(fd):
|
|
48
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
49
|
+
|
|
50
|
+
def _unlock(fd):
|
|
51
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _stats_path():
|
|
55
|
+
"""Return the path to the usage stats file."""
|
|
56
|
+
custom = os.getenv("PTY_STATS_FILE")
|
|
57
|
+
if custom:
|
|
58
|
+
return Path(custom)
|
|
59
|
+
return Path.home() / ".protegrity" / "usage_stats.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _empty_stats():
|
|
63
|
+
"""Return a fresh empty stats structure."""
|
|
64
|
+
now = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
65
|
+
return {
|
|
66
|
+
"schema_version": _SCHEMA_VERSION,
|
|
67
|
+
"collected_since": now,
|
|
68
|
+
"last_updated": now,
|
|
69
|
+
"data_elements": {},
|
|
70
|
+
"policy_users": {},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_stats(path):
|
|
75
|
+
"""Read existing stats file. Returns None if not found or invalid."""
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
with open(path, "r") as f:
|
|
80
|
+
data = json.load(f)
|
|
81
|
+
if data.get("schema_version") == _SCHEMA_VERSION:
|
|
82
|
+
return data
|
|
83
|
+
return None
|
|
84
|
+
except (json.JSONDecodeError, OSError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _merge_session(existing, session_data):
|
|
89
|
+
"""Merge a session's collected stats into the existing stats structure.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
existing: The full stats dict (will be mutated).
|
|
93
|
+
session_data: Dict from UsageCollector.get_session_data().
|
|
94
|
+
"""
|
|
95
|
+
now = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
96
|
+
existing["last_updated"] = now
|
|
97
|
+
|
|
98
|
+
# Merge data elements
|
|
99
|
+
for de_name, de_stats in session_data.get("data_elements", {}).items():
|
|
100
|
+
if de_name not in existing["data_elements"]:
|
|
101
|
+
existing["data_elements"][de_name] = {
|
|
102
|
+
"protect_count": 0,
|
|
103
|
+
"unprotect_count": 0,
|
|
104
|
+
"reprotect_source_count": 0,
|
|
105
|
+
"reprotect_target_count": 0,
|
|
106
|
+
"first_used": de_stats["first_used"],
|
|
107
|
+
"last_used": de_stats["last_used"],
|
|
108
|
+
}
|
|
109
|
+
target = existing["data_elements"][de_name]
|
|
110
|
+
target["protect_count"] += de_stats["protect_count"]
|
|
111
|
+
target["unprotect_count"] += de_stats["unprotect_count"]
|
|
112
|
+
target["reprotect_source_count"] += de_stats["reprotect_source_count"]
|
|
113
|
+
target["reprotect_target_count"] += de_stats["reprotect_target_count"]
|
|
114
|
+
# Update last_used if session is more recent
|
|
115
|
+
if de_stats["last_used"] > target.get("last_used", ""):
|
|
116
|
+
target["last_used"] = de_stats["last_used"]
|
|
117
|
+
# Keep earliest first_used
|
|
118
|
+
if de_stats["first_used"] < target.get("first_used", "9999-12-31"):
|
|
119
|
+
target["first_used"] = de_stats["first_used"]
|
|
120
|
+
|
|
121
|
+
# Merge policy user
|
|
122
|
+
user = session_data.get("user")
|
|
123
|
+
if user:
|
|
124
|
+
today = datetime.utcnow().strftime("%Y-%m-%d")
|
|
125
|
+
if user not in existing["policy_users"]:
|
|
126
|
+
existing["policy_users"][user] = {
|
|
127
|
+
"session_count": 0,
|
|
128
|
+
"first_used": today,
|
|
129
|
+
"last_used": today,
|
|
130
|
+
}
|
|
131
|
+
existing["policy_users"][user]["session_count"] += 1
|
|
132
|
+
existing["policy_users"][user]["last_used"] = today
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def flush_stats(session_data):
|
|
136
|
+
"""Flush session stats to disk with file locking.
|
|
137
|
+
|
|
138
|
+
Reads existing stats, merges in session data, writes back atomically.
|
|
139
|
+
Degrades gracefully — never raises exceptions to caller.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
session_data: Dict from UsageCollector.get_session_data().
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
# During interpreter shutdown, modules may be None
|
|
146
|
+
if json is None or os is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
path = _stats_path()
|
|
150
|
+
|
|
151
|
+
# Ensure directory exists
|
|
152
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
|
|
154
|
+
# Open (or create) with exclusive lock
|
|
155
|
+
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o644)
|
|
156
|
+
try:
|
|
157
|
+
_lock_exclusive(fd)
|
|
158
|
+
# Read existing
|
|
159
|
+
with os.fdopen(os.dup(fd), "r") as f:
|
|
160
|
+
try:
|
|
161
|
+
content = f.read()
|
|
162
|
+
existing = json.loads(content) if content.strip() else None
|
|
163
|
+
except (json.JSONDecodeError, OSError):
|
|
164
|
+
existing = None
|
|
165
|
+
|
|
166
|
+
if existing is None or existing.get("schema_version") != _SCHEMA_VERSION:
|
|
167
|
+
existing = _empty_stats()
|
|
168
|
+
|
|
169
|
+
# Merge
|
|
170
|
+
_merge_session(existing, session_data)
|
|
171
|
+
|
|
172
|
+
# Write back (truncate and rewrite)
|
|
173
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
174
|
+
os.ftruncate(fd, 0)
|
|
175
|
+
data_bytes = json.dumps(existing, indent=2).encode("utf-8")
|
|
176
|
+
os.write(fd, data_bytes)
|
|
177
|
+
finally:
|
|
178
|
+
_unlock(fd)
|
|
179
|
+
os.close(fd)
|
|
180
|
+
|
|
181
|
+
except Exception as e:
|
|
182
|
+
# Graceful degradation: log warning, never impact SDK operation
|
|
183
|
+
# During interpreter shutdown, modules may already be torn down
|
|
184
|
+
if json is not None and os is not None:
|
|
185
|
+
logger.warning("Failed to write usage stats: %s", e)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides helper functions for encoding and decoding data, particularly focusing on
|
|
3
|
+
base64 transformations and character set handling. It includes functions to convert between byte
|
|
4
|
+
strings and base64-encoded strings, as well as functions to decode byte data based on specified
|
|
5
|
+
character sets and operation types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
from appython.utils.constants import Charset, OP_TYPE as op_type
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def convert_b64_bytes_b64_string(data: bytes) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Encodes a byte string into a base64-encoded UTF-8 string.
|
|
15
|
+
|
|
16
|
+
Parameters:
|
|
17
|
+
data (bytes): The input byte data to encode.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
str: A base64-encoded string representation of the input bytes.
|
|
21
|
+
"""
|
|
22
|
+
return base64.b64encode(data).decode("utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def convert_bytes_b64_string(data: bytes, charset: str) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Encodes byte data into a base64 string and re-encodes it using the specified character set.
|
|
28
|
+
|
|
29
|
+
Parameters:
|
|
30
|
+
data (bytes): The input byte data to encode.
|
|
31
|
+
charset (str): The character set to use for encoding the base64 string.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
str: A base64-encoded string re-encoded using the specified charset.
|
|
35
|
+
"""
|
|
36
|
+
return base64.b64encode(data).decode("ascii").encode(charset).decode(charset)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_bytes(data: bytes, charset: Charset, is_enc: bool, operation: str) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Decodes byte data into a string, optionally applying base64 decoding based on the operation type.
|
|
42
|
+
|
|
43
|
+
Parameters:
|
|
44
|
+
data (bytes): The byte data to decode.
|
|
45
|
+
charset (Charset): The character set to use for decoding.
|
|
46
|
+
is_enc (bool): Indicates whether the data is base64-encoded.
|
|
47
|
+
operation (str): The operation type (e.g., 'PROTECT', 'UNPROTECT', 'REPROTECT').
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
str: The decoded string.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
Exception: If the operation type is unsupported.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
if not is_enc:
|
|
57
|
+
return data.decode(charset.name.lower())
|
|
58
|
+
if op_type[operation] in ["UNPROTECT", "REPROTECT"]:
|
|
59
|
+
return convert_b64_bytes_b64_string(data)
|
|
60
|
+
return convert_bytes_b64_string(data, charset.name.lower())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def encode_to_base64_string(data) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Converts any input data to a UTF-8 base64-encoded string.
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
data: The input data to encode (typically str, int, float, etc.).
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
str: A base64-encoded UTF-8 string representation of the input.
|
|
72
|
+
"""
|
|
73
|
+
return base64.b64encode(str(data).encode("utf-8")).decode("utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_str_from_b64(data: str):
|
|
77
|
+
"""
|
|
78
|
+
Decodes a base64-encoded UTF-8 string back to its original string form.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
data (str): The base64-encoded string.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
str: The decoded original string.
|
|
85
|
+
"""
|
|
86
|
+
return base64.b64decode(data.encode("utf-8")).decode("utf-8")
|