pjdev-sn-sdk 4.6.4__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,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "4.6.4"
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,161 @@
1
+ import asyncio
2
+ import time
3
+ from functools import wraps
4
+ from typing import Optional, List, Any
5
+
6
+ import httpx
7
+ from httpx import HTTPStatusError
8
+ from loguru import logger
9
+
10
+
11
+ async def log_server_addr(response):
12
+ network_stream_ext = response.extensions["network_stream"]
13
+ logger.debug(f"Client IP, Port: {network_stream_ext.get_extra_info('client_addr')}")
14
+ logger.debug(f"Server IP, Port: {network_stream_ext.get_extra_info('server_addr')}")
15
+
16
+
17
+ async def log_request_headers(request):
18
+ header_keys = [k for k in request.headers.keys()]
19
+ logger.debug("Request event hook - Headers: " + " | ".join(header_keys))
20
+ logger.debug(
21
+ f"Request event hook: {request.method} {request.url} - Waiting for response"
22
+ )
23
+
24
+
25
+ async def log_response_headers(response):
26
+ request = response.request
27
+ header_keys = [k for k in response.headers.keys()]
28
+ logger.debug("Request event hook - Headers: " + " | ".join(header_keys))
29
+ logger.debug(
30
+ f"Response event hook: Content-Type -> {response.headers.get('content-type')}"
31
+ )
32
+ logger.debug(
33
+ f"Response event hook: Content-encoding -> {response.headers.get('content-encoding')}"
34
+ )
35
+ logger.debug(
36
+ f"Response event hook: transfer-encoding -> {response.headers.get('transfer-encoding')}"
37
+ )
38
+ logger.debug(f"Response event hook: Server -> {response.headers.get('server')}")
39
+ logger.debug(
40
+ f"Response event hook: {request.method} {request.url} - Status {response.status_code}"
41
+ )
42
+
43
+
44
+ def get_error_message(eg: ExceptionGroup) -> str:
45
+ error_messages = []
46
+ for e in eg.exceptions:
47
+ if isinstance(e, httpx.HTTPStatusError):
48
+ error_messages.append(f"{e.response.status_code} -> " + e.response.text)
49
+ else:
50
+ error_messages.append(str(e))
51
+ error = " | ".join(f"{e}" for e in error_messages)
52
+ logger.error(error)
53
+
54
+ return error
55
+
56
+
57
+ def retry_http(max_attempts: int, delay_seconds: int):
58
+ def decorator(func):
59
+ @wraps(func)
60
+ def wrapper(*args, **kwargs):
61
+ attempts = 0
62
+ while attempts < max_attempts:
63
+ try:
64
+ return func(*args, **kwargs)
65
+ except Exception as e:
66
+ attempts += 1
67
+ logger.warning(e)
68
+ logger.warning(f"Attempt {attempts}/{max_attempts} failed: {e}")
69
+ time.sleep(delay_seconds**attempts)
70
+ raise Exception(f"Failed after {max_attempts} attempts")
71
+
72
+ return wrapper
73
+
74
+ return decorator
75
+
76
+
77
+ def async_retry_http(
78
+ max_attempts: int,
79
+ delay_seconds: int,
80
+ default_value: Optional[Any] = None,
81
+ status_codes_to_ignore: Optional[List[int]] = None,
82
+ ):
83
+ def decorator(func):
84
+ @wraps(func)
85
+ async def wrapper(*args, **kwargs):
86
+ attempts = 0
87
+ exceptions = []
88
+
89
+ while attempts < max_attempts:
90
+ try:
91
+ return await func(*args, **kwargs)
92
+ except HTTPStatusError as e:
93
+ logger.warning(e)
94
+ logger.warning(
95
+ f"{e.response.status_code}: {e.response.reason_phrase}"
96
+ )
97
+ logger.warning(f"{e.response.text}")
98
+ exceptions.append(e)
99
+ if (
100
+ status_codes_to_ignore
101
+ and e.response.status_code in status_codes_to_ignore
102
+ ):
103
+ break
104
+ except Exception as e:
105
+ logger.warning("unexpected exception")
106
+ logger.warning(e)
107
+ exceptions.append(e)
108
+
109
+ attempts += 1
110
+ if attempts == max_attempts:
111
+ break
112
+ logger.warning(f"Attempt {attempts}/{max_attempts} failed")
113
+ total_delay = delay_seconds**attempts
114
+ logger.warning(f"Retrying in {total_delay} seconds...")
115
+ await asyncio.sleep(total_delay)
116
+ if default_value is None:
117
+ raise ExceptionGroup(
118
+ f"Failed after {max_attempts} attempts", exceptions
119
+ )
120
+ logger.error(f"Failed after {max_attempts} attempts")
121
+ return default_value
122
+
123
+ return wrapper
124
+
125
+ return decorator
126
+
127
+
128
+ def record_time(log: bool = False):
129
+ def decorator(func):
130
+ if asyncio.iscoroutinefunction(func):
131
+
132
+ @wraps(func)
133
+ async def wrap_func(*args, **kwargs):
134
+ t1 = time.time()
135
+ result = await func(*args, **kwargs)
136
+ t2 = time.time()
137
+ if log:
138
+ logger.info(
139
+ f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
140
+ )
141
+ return result
142
+
143
+ return wrap_func
144
+
145
+ else:
146
+
147
+ @wraps(func)
148
+ def wrap_func(*args, **kwargs):
149
+ t1 = time.time()
150
+ result = func(*args, **kwargs)
151
+ t2 = time.time()
152
+ if log:
153
+ logger.info(
154
+ f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
155
+ )
156
+
157
+ return result
158
+
159
+ return wrap_func
160
+
161
+ return decorator
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from pjdev_sn_sdk.models import Config
5
+
6
+ __ctx = {}
7
+
8
+
9
+ def get_config() -> Config:
10
+ return __ctx["config"]
11
+
12
+
13
+ async def init(env_path: Optional[Path] = None) -> None:
14
+ Config.model_config.update(env_file=env_path)
15
+ __ctx["config"] = Config()
16
+
17
+ if __ctx["config"].output_path is None:
18
+ __ctx["config"].output_path = env_path / "output"
19
+
@@ -0,0 +1,98 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ from loguru import logger
5
+
6
+
7
+ def extract_control(ap: Optional[str]) -> Optional[str]:
8
+ """
9
+ Extract the NIST 800-53 control_id from an assessment procedure (ap) value.
10
+
11
+ What it does:
12
+ - Accepts a wide variety of AP formats (case-insensitive).
13
+ - Removes [r5] revision markers (wherever they appear).
14
+ - Parses the control family (e.g., AC) and control number (e.g., 2), removing leading zeros.
15
+ - Captures only the immediate numeric enhancement in parentheses (e.g., (3)).
16
+ - Ignores objective letters like (a), (b), roman numerals, dot suffixes like .01, and other notes.
17
+
18
+ Examples:
19
+ - "AC-2(3)(a)" -> "AC-02(03)"
20
+ - "[r5] ac-02 (03)" -> "AC-02(03)"
21
+ - "PM-16 (01) (b)" -> "PM-16(01)"
22
+ - "RA-5(5)(A)(1)" -> "RA-05(05)"
23
+ - "SC-7 (12).01(a)" -> "SC-07(12)"
24
+ - "AU-3" -> "AU-03"
25
+ - "invalid" -> None
26
+
27
+ :param ap: The assessment procedure string.
28
+ :return: The related control_id (e.g., 'AC-2' or 'AC-2(3)') or None if it can't be parsed.
29
+ """
30
+ if not ap:
31
+ return None
32
+
33
+ s = ap.strip()
34
+ if not s:
35
+ return None
36
+
37
+ # Remove revision markers like [r5] anywhere in the string (case-insensitive).
38
+ s = re.sub(r"\[\s*r?\s*5\s*\]", "", s, flags=re.IGNORECASE)
39
+
40
+ # Match the base control family and number (e.g., AC-2, CM-06, etc.)
41
+ # NOTE: Use a negative lookahead to avoid requiring a word boundary after the number.
42
+ # This allows inputs like "CA-05b" to match the base "CA-05" while excluding trailing digits.
43
+ base_match = re.search(r"(?i)\b([A-Z]{2})\s*-\s*0*([0-9]{1,3})(?!\d)", s)
44
+
45
+ if not base_match:
46
+ logger.debug(f"Could not find control family and number in AP: {ap}")
47
+ return None
48
+
49
+ family = base_match.group(1).upper()
50
+ number = str(int(base_match.group(2))) # strip any leading zeros by int conversion
51
+ control_id = f"{family}-{'0' if len(number) == 1 else ''}{number}"
52
+
53
+ # Starting right after the base match, capture immediate numeric parentheses like (03) -> (3).
54
+ idx = base_match.end()
55
+ paren_token = re.compile(r"\s*\(\s*([^)]+)\s*\)")
56
+
57
+ while True:
58
+ m = paren_token.match(s, idx)
59
+ if not m:
60
+ break
61
+
62
+ token = m.group(1)
63
+
64
+ # Only accept purely numeric tokens as part of the control identifier.
65
+ # Stop at the first non-numeric token (e.g., letters like (a), roman numerals, etc.).
66
+ if re.fullmatch(r"0*\d+", token):
67
+ sub_number = f"{int(token)}"
68
+ control_id += f"({'0' if len(sub_number) == 1 else ''}{sub_number})" # normalize 0-padded numbers
69
+ idx = m.end()
70
+ # Continue in case there is another numeric enhancement directly chained.
71
+ continue
72
+ else:
73
+ break
74
+
75
+ return control_id
76
+
77
+
78
+ def strip_leading_zeros_after_dash_or_dot(s: str) -> str:
79
+ return re.sub(r"(?<=[-.(])0+(?=\d)", "", s)
80
+
81
+
82
+ def extract_control_requirement(ap: Optional[str]) -> Optional[str]:
83
+ if not ap:
84
+ return None
85
+
86
+ control_id = extract_control(ap=ap)
87
+ if not control_id:
88
+ return None
89
+
90
+ sn_control_id = strip_leading_zeros_after_dash_or_dot(control_id)
91
+ stripped_ap = strip_leading_zeros_after_dash_or_dot(ap).upper()
92
+
93
+ part = stripped_ap.removeprefix(sn_control_id).removeprefix(".").split(".")[0]
94
+
95
+ if not part:
96
+ return None
97
+
98
+ return f"{sn_control_id}.{part.strip().lower()}"
pjdev_sn_sdk/models.py ADDED
@@ -0,0 +1,123 @@
1
+ import enum
2
+ from pathlib import Path
3
+
4
+ from pydantic import BaseModel, computed_field, BeforeValidator, Field, ConfigDict
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+ from datetime import datetime
7
+ from typing import Dict, List, Optional, Annotated
8
+
9
+ class ServiceNowEnvironment(enum.StrEnum):
10
+ PJ = "pj"
11
+ SubProd1 = "subprod1"
12
+ Dev = "dev"
13
+ Test = "test"
14
+ PreProd = "preprod"
15
+ Prod = "prod"
16
+
17
+
18
+ class SnowTables(BaseModel):
19
+ auth_boundary_table_name: str = "sn_irm_cont_auth_authorization_boundary"
20
+ auth_pack_table_name: str = "sn_irm_cont_auth_pack"
21
+ info_type_definition_table_name: str = (
22
+ "sn_irm_cont_auth_information_type_definition"
23
+ )
24
+ info_type_table_name: str = "sn_irm_cont_auth_information_type"
25
+ baseline_control_table_name: str = "sn_irm_cont_auth_baseline_control_objective"
26
+ nist_control_table_name: str = "sn_grc_content"
27
+ control_table_name: str = "sn_compliance_control"
28
+ control_overlay_table_name: str = "sn_compliance_policy"
29
+ control_objective_table_name: str = "sn_compliance_policy_statement"
30
+ poam_issue_rating_table_name: str = "sn_grc_issue_rating"
31
+ poam_table_name: str = "sn_grc_issue"
32
+ poam_control_m2m_table_name: str = "sn_grc_m2m_issue_item"
33
+ auth_pack_poam_m2m_table_name: str = "sn_irm_cont_auth_m2m_issue_auth_pack"
34
+ milestone_table_name: str = "sn_irm_cont_auth_milestone_task"
35
+ cmdb_ci_table_name: str = "cmdb_ci"
36
+ cmdb_hardware: str = "cmdb_ci_hardware"
37
+ control_parts_table: str = "sn_compliance_policy_stmt_requirement"
38
+ control_objective_control_part_m2m_table: str = (
39
+ "sn_compliance_m2m_policy_stmt_policy_stmt_rqmt"
40
+ )
41
+ item_generation_action_event_queue_table: str = "sn_grc_item_generation_action_event_queue"
42
+
43
+
44
+ snow_tables = SnowTables()
45
+
46
+
47
+ class Config(BaseSettings):
48
+ scripted_api_base_path: str = "/api/sn_irm_cont_auth/auth_pack"
49
+ http_retry_max_count = 1000
50
+ environment: ServiceNowEnvironment = ServiceNowEnvironment.SubProd1
51
+ api_key: Optional[str] = None
52
+ api_username: Optional[str] = None
53
+ api_password: Optional[str] = None
54
+ output_path: Optional[Path] = None
55
+
56
+ model_config = SettingsConfigDict(
57
+ env_prefix="SN_",
58
+ case_sensitive=False,
59
+ extra="ignore",
60
+ )
61
+
62
+ @computed_field
63
+ def sn_api_url(self) -> str:
64
+ match self.environment:
65
+ case ServiceNowEnvironment.PreProd:
66
+ return "https://vaoitpreprod.servicenowservices.com"
67
+ case ServiceNowEnvironment.SubProd1:
68
+ return "https://vasubprod1.servicenowservices.com"
69
+ case ServiceNowEnvironment.Prod:
70
+ return "https://yourit.va.gov"
71
+ case ServiceNowEnvironment.Dev:
72
+ return "https://yourit-dev.va.gov"
73
+ case ServiceNowEnvironment.Test:
74
+ return "https://vaoittest.servicenowservices.com"
75
+ case ServiceNowEnvironment.PJ:
76
+ return "https://dev190624.service-now.com"
77
+
78
+
79
+ class RMFStep(enum.Enum):
80
+ Prepare = 1
81
+ Categorize = 8
82
+ Select = 9
83
+ Implement = 10
84
+ Assess = 11
85
+ Authorize = 12
86
+ Monitor = 13
87
+
88
+
89
+ class SnowBase(BaseModel):
90
+ sys_id: str
91
+
92
+ @classmethod
93
+ def get_fields(cls) -> List[str]:
94
+ fields = []
95
+ for k, fi in cls.model_fields.items():
96
+ if fi.alias:
97
+ fields.append(fi.alias)
98
+ else:
99
+ fields.append(k)
100
+ return fields
101
+
102
+ @classmethod
103
+ def get_fields_query_param_value(cls) -> str:
104
+ return ",".join(cls.get_fields())
105
+
106
+ @classmethod
107
+ def get_df_column_rename_map(cls) -> Dict[str, str]:
108
+ original_cols = cls.get_fields()
109
+ cols = [k for k in cls.model_fields.keys()]
110
+ return dict(zip(original_cols, cols))
111
+
112
+
113
+ class SnowAuthPack(SnowBase):
114
+ name: str
115
+ number: str
116
+ step: int
117
+ authorization_date: Optional[str] = None
118
+ next_authorization_date: Optional[str] = None
119
+ authorization_comments: Optional[str] = None
120
+ authorization_decision: Optional[str] = None
121
+ ongoing_authorization: Optional[str] = None
122
+
123
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, strict=False)
@@ -0,0 +1,628 @@
1
+ from contextlib import asynccontextmanager
2
+ from pathlib import Path
3
+ import ssl
4
+ from typing import (
5
+ Any,
6
+ Callable,
7
+ Dict,
8
+ List,
9
+ Tuple,
10
+ Optional,
11
+ AsyncIterator,
12
+ )
13
+
14
+ from httpx import AsyncClient
15
+ from loguru import logger
16
+
17
+ import httpx
18
+ import asyncio
19
+
20
+ from pjdev_sn_sdk.api_utilities import async_retry_http, log_server_addr, log_response_headers, log_request_headers
21
+ from pjdev_sn_sdk.config_service import get_config
22
+
23
+
24
+ async def check_for_202(r: httpx.Response) -> None:
25
+ if r.status_code == 202:
26
+ logger.warning(
27
+ f"202 status code recieved for {r.request.method.upper()} {r.url.path}"
28
+ )
29
+ await asyncio.sleep(5)
30
+ raise httpx.HTTPError("Too many requests")
31
+
32
+
33
+ class SnApiKeyAuth(httpx.Auth):
34
+ def __init__(self, token):
35
+ self.token = token
36
+
37
+ def auth_flow(self, request):
38
+ request.headers["x-sn-apikey"] = self.token
39
+ yield request
40
+
41
+
42
+ def get_http_client() -> httpx.AsyncClient:
43
+ config = get_config()
44
+ if not config.api_key:
45
+ if not config.api_username or not config.api_password:
46
+ raise ValueError("Must set username and password")
47
+ auth = httpx.BasicAuth(
48
+ username=config.api_username, password=config.api_password
49
+ )
50
+ else:
51
+ auth = SnApiKeyAuth(config.api_key)
52
+
53
+ return httpx.AsyncClient(
54
+ base_url=config.sn_api_url,
55
+ auth=auth,
56
+ verify=ssl.create_default_context(),
57
+ timeout=None,
58
+ event_hooks={
59
+ "response": [log_server_addr, log_response_headers],
60
+ "request": [log_request_headers],
61
+ },
62
+ )
63
+
64
+
65
+ @asynccontextmanager
66
+ async def http_client() -> AsyncIterator[httpx.AsyncClient]:
67
+ async with get_http_client() as _client:
68
+ yield _client
69
+
70
+
71
+ @async_retry_http(
72
+ max_attempts=get_config().http_retry_max_count,
73
+ delay_seconds=1,
74
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
75
+ )
76
+ async def add_table_row(
77
+ table_name: str,
78
+ payload: Dict[str, Any],
79
+ fields: Optional[str] = None,
80
+ client: Optional[AsyncClient] = None,
81
+ ) -> Dict[str, Any]:
82
+ async def _exec(_client: AsyncClient) -> Dict[str, Any]:
83
+ params = {}
84
+ if fields:
85
+ params["sysparm_fields"] = fields
86
+ r = await _client.post(
87
+ f"/api/now/table/{table_name}", json=payload, params=params
88
+ )
89
+ r.raise_for_status()
90
+ await check_for_202(r=r)
91
+
92
+ json_data = r.json()
93
+ logger.info(f"Added row to {table_name}")
94
+ return json_data["result"]
95
+
96
+ if not client:
97
+ async with http_client() as _client:
98
+ return await _exec(_client)
99
+ else:
100
+ return await _exec(client)
101
+
102
+
103
+ @async_retry_http(
104
+ max_attempts=get_config().http_retry_max_count,
105
+ delay_seconds=1,
106
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
107
+ )
108
+ async def delete_table_row(
109
+ table_name: str, sys_id: str, client: Optional[AsyncClient] = None
110
+ ) -> None:
111
+ async def _exec(_client: AsyncClient) -> None:
112
+ r = await _client.delete(f"/api/now/table/{table_name}/{sys_id}")
113
+ if not r.is_success and r.status_code != 404:
114
+ r.raise_for_status()
115
+ await check_for_202(r=r)
116
+ if r.status_code != 404:
117
+ logger.info(f"Removed row {sys_id} from {table_name}")
118
+
119
+ if not client:
120
+ async with http_client() as _client:
121
+ await _exec(_client)
122
+ else:
123
+ await _exec(client)
124
+
125
+
126
+ async def get_table_rows(
127
+ table_name: str,
128
+ query: Optional[str] = None,
129
+ fields: Optional[str] = None,
130
+ order_by_fields: Optional[List[str]] = None,
131
+ sysparm_limit: Optional[int] = None,
132
+ paginate: bool = False,
133
+ extra_parms: Optional[Dict] = None,
134
+ concurrency: Optional[int] = None,
135
+ process_page_data_func: Optional[Callable[[List[Any]], None]] = None,
136
+ get_display_values: Optional[bool] = False,
137
+ ) -> Tuple[List[Any], int]:
138
+ url = f"/api/now/table/{table_name}"
139
+ params: Dict[str, Any] = {}
140
+
141
+ # Build query locally (avoid confusing in-place += on the function arg).
142
+ full_query = query
143
+ if order_by_fields:
144
+ order_by_query = "^".join(f"ORDERBY{f}" for f in order_by_fields)
145
+ full_query = f"{full_query}^{order_by_query}" if full_query else order_by_query
146
+
147
+ if full_query:
148
+ params["sysparm_query"] = full_query
149
+
150
+ if fields:
151
+ params["sysparm_fields"] = fields
152
+
153
+ if get_display_values:
154
+ params["sysparm_display_value"] = True
155
+
156
+ if extra_parms:
157
+ params = {**params, **extra_parms}
158
+
159
+ if sysparm_limit is not None:
160
+ page_size = sysparm_limit
161
+ elif paginate:
162
+ page_size = 5000
163
+ else:
164
+ page_size = 5000
165
+
166
+ params["sysparm_limit"] = page_size
167
+
168
+ async with http_client() as client:
169
+ first_result = await client.get(url, params=params)
170
+ first_result.raise_for_status()
171
+ await check_for_202(r=first_result)
172
+
173
+ try:
174
+ first_rows = first_result.json()["result"]
175
+ except Exception:
176
+ logger.exception(f"Unexpected response payload for table: {table_name}")
177
+ raise
178
+
179
+ total_count_str = first_result.headers.get("X-Total-Count")
180
+ if not total_count_str:
181
+ logger.warning(
182
+ f"X-Total-Count header was not present in request to table: {table_name}"
183
+ )
184
+ # Best-effort: if header is missing, keep count consistent with returned rows.
185
+ return first_rows, len(first_rows)
186
+
187
+ try:
188
+ total_count = int(total_count_str.strip())
189
+ except (ValueError, AttributeError):
190
+ logger.warning(
191
+ f"Could not parse X-Total-Count={total_count_str!r} for table: {table_name}"
192
+ )
193
+ return first_rows, len(first_rows)
194
+
195
+ if total_count <= 0:
196
+ return [], 0
197
+
198
+ if not paginate:
199
+ return first_rows, total_count
200
+
201
+ # Paginate remaining pages.
202
+ concurrency_value = concurrency or 1
203
+
204
+ sem = asyncio.Semaphore(value=concurrency_value)
205
+ logger.info(
206
+ f"total records for {table_name}: {total_count} (page_size={page_size}, concurrency={concurrency_value})"
207
+ )
208
+
209
+ if process_page_data_func:
210
+ process_page_data_func(first_rows)
211
+ logger.info("Finished processing data for initial page")
212
+
213
+ @async_retry_http(
214
+ max_attempts=get_config().http_retry_max_count,
215
+ delay_seconds=1,
216
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
217
+ )
218
+ async def __run(offset: int) -> List[Any]:
219
+ async with sem:
220
+ local_params = {**params, "sysparm_offset": offset}
221
+ logger.info(f"Offset: {offset}")
222
+ async with get_http_client() as _client:
223
+ r = await _client.get(url, params=local_params)
224
+ r.raise_for_status()
225
+ await check_for_202(r=r)
226
+ offset_data = r.json()["result"]
227
+
228
+ if total_count - offset >= page_size and len(offset_data) < page_size:
229
+ raise Exception(
230
+ f"Did not receive entire payload: {len(offset_data)} rows"
231
+ )
232
+
233
+ if process_page_data_func:
234
+ process_page_data_func(offset_data)
235
+ logger.info(f"Finished processing data for offset {offset} ")
236
+
237
+ return offset_data
238
+
239
+ # We already fetched the first page (offset 0). Start from the next offset.
240
+ offsets = range(page_size, total_count, page_size)
241
+ results_list = await asyncio.gather(*(__run(off) for off in offsets))
242
+
243
+ all_rows = [*first_rows, *(item for sub in results_list for item in sub)]
244
+ return all_rows, total_count
245
+
246
+
247
+ @async_retry_http(
248
+ max_attempts=get_config().http_retry_max_count,
249
+ delay_seconds=1,
250
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
251
+ )
252
+ async def patch_table_row(
253
+ table_name: str,
254
+ sys_id: str,
255
+ payload: Dict[str, Any],
256
+ fields: Optional[List[str]] = None,
257
+ client: Optional[AsyncClient] = None,
258
+ ) -> Dict[str, Any]:
259
+ async def _exec(_client: AsyncClient) -> Dict[str, Any]:
260
+ params = {}
261
+
262
+ if fields:
263
+ params["sysparm_fields"] = ",".join(fields)
264
+
265
+ r = await _client.patch(
266
+ f"/api/now/table/{table_name}/{sys_id}", json=payload, params=params
267
+ )
268
+ r.raise_for_status()
269
+ await check_for_202(r=r)
270
+
271
+ logger.info(f"Updated row to {table_name}")
272
+
273
+ return r.json()["result"]
274
+
275
+ if not client:
276
+ async with http_client() as _client:
277
+ return await _exec(_client)
278
+ else:
279
+ return await _exec(client)
280
+
281
+
282
+ @async_retry_http(
283
+ max_attempts=get_config().http_retry_max_count,
284
+ delay_seconds=1,
285
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
286
+ )
287
+ async def move_auth_package_to_step(
288
+ target_step: int, auth_package_sys_id: str, client: Optional[AsyncClient] = None
289
+ ) -> Dict:
290
+ scripted_api_base_path = get_config().scripted_api_base_path
291
+
292
+ async def _exec(_client: AsyncClient) -> Dict:
293
+ r = await _client.patch(
294
+ f"{scripted_api_base_path}/{auth_package_sys_id}",
295
+ json={"target_step": target_step},
296
+ )
297
+ r.raise_for_status()
298
+ await check_for_202(r=r)
299
+ return r.json()["result"]
300
+
301
+ if not client:
302
+ async with http_client() as _client:
303
+ return await _exec(_client)
304
+ else:
305
+ return await _exec(client)
306
+
307
+
308
+ async def update_poam(
309
+ auth_package_sys_id: str,
310
+ poam_sys_id: str,
311
+ payload: Dict[str, Any],
312
+ client: Optional[AsyncClient] = None,
313
+ ) -> Dict:
314
+ config = get_config()
315
+
316
+ async def _exec(_client: AsyncClient) -> Dict:
317
+ r = await _client.patch(
318
+ f"{config.scripted_api_base_path}/{auth_package_sys_id}/poam/{poam_sys_id}",
319
+ json=payload,
320
+ )
321
+ r.raise_for_status()
322
+ await check_for_202(r=r)
323
+ return r.json()["result"]
324
+
325
+ if not client:
326
+ async with http_client() as _client:
327
+ return await _exec(_client)
328
+ else:
329
+ return await _exec(client)
330
+
331
+
332
+ @async_retry_http(
333
+ max_attempts=get_config().http_retry_max_count,
334
+ delay_seconds=1,
335
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
336
+ )
337
+ async def create_poam(
338
+ auth_package_sys_id: str, payload: Dict, client: Optional[AsyncClient] = None
339
+ ) -> Dict:
340
+ config = get_config()
341
+
342
+ async def _exec(_client: AsyncClient) -> Dict:
343
+ r = await _client.post(
344
+ f"{config.scripted_api_base_path}/{auth_package_sys_id}/poam",
345
+ json=payload,
346
+ )
347
+ r.raise_for_status()
348
+ await check_for_202(r=r)
349
+ return r.json()["result"]
350
+
351
+ if not client:
352
+ async with http_client() as _client:
353
+ return await _exec(_client)
354
+ else:
355
+ return await _exec(client)
356
+
357
+
358
+ @async_retry_http(
359
+ max_attempts=get_config().http_retry_max_count,
360
+ delay_seconds=1,
361
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
362
+ )
363
+ async def update_auth_package_authorize_info(
364
+ auth_package_sys_id: str,
365
+ payload: Dict[str, Any],
366
+ client: Optional[AsyncClient] = None,
367
+ ) -> Dict:
368
+ config = get_config()
369
+
370
+ async def _exec(_client: AsyncClient) -> Dict:
371
+ r = await _client.patch(
372
+ f"{config.scripted_api_base_path}/{auth_package_sys_id}/authorize",
373
+ json=payload,
374
+ )
375
+ r.raise_for_status()
376
+ await check_for_202(r=r)
377
+ return r.json()["result"]
378
+
379
+ if not client:
380
+ async with http_client() as _client:
381
+ return await _exec(_client)
382
+ else:
383
+ return await _exec(client)
384
+
385
+
386
+ @async_retry_http(
387
+ max_attempts=get_config().http_retry_max_count,
388
+ delay_seconds=1,
389
+ status_codes_to_ignore=[400, 401, 403, 405, 404],
390
+ )
391
+ async def update_control_status(
392
+ auth_pack_sys_id: str,
393
+ compliance_control_sys_id: str,
394
+ payload: Dict[str, Any],
395
+ client: Optional[AsyncClient] = None,
396
+ ) -> Dict:
397
+ config = get_config()
398
+
399
+ async def _exec(_client: AsyncClient) -> Dict:
400
+ r = await _client.patch(
401
+ f"{config.scripted_api_base_path}/{auth_pack_sys_id}/control/{compliance_control_sys_id}",
402
+ json=payload,
403
+ )
404
+ r.raise_for_status()
405
+ await check_for_202(r=r)
406
+ return r.json()["result"]
407
+
408
+ if not client:
409
+ async with http_client() as _client:
410
+ return await _exec(_client)
411
+ else:
412
+ return await _exec(client)
413
+
414
+
415
+ __sem = asyncio.Semaphore(10)
416
+
417
+
418
+ @async_retry_http(
419
+ max_attempts=get_config().http_retry_max_count,
420
+ delay_seconds=1,
421
+ status_codes_to_ignore=[400, 401, 403, 404],
422
+ )
423
+ async def associate_file_reference_to_compliance_control(
424
+ compliance_control_sys_id: str, text: str, client: Optional[AsyncClient] = None
425
+ ) -> Dict[str, Any]:
426
+ config = get_config()
427
+
428
+ async def _exec(_client: AsyncClient) -> Dict[str, Any]:
429
+ payload = {"u_associated_artifacts": text}
430
+
431
+ async with __sem:
432
+ r = await _client.patch(
433
+ f"{config.scripted_api_base_path}/control_artifact_association/{compliance_control_sys_id}",
434
+ json=payload,
435
+ )
436
+ r.raise_for_status()
437
+ await check_for_202(r=r)
438
+ updated_control = r.json()["result"]
439
+
440
+ return updated_control["current_values"]
441
+
442
+ if not client:
443
+ async with http_client() as _client:
444
+ return await _exec(_client)
445
+ else:
446
+ return await _exec(client)
447
+
448
+
449
+ @async_retry_http(
450
+ max_attempts=get_config().http_retry_max_count,
451
+ delay_seconds=1,
452
+ status_codes_to_ignore=[400, 401, 403, 404],
453
+ )
454
+ async def get_attachment_sys_id(
455
+ table_name: str,
456
+ table_row_sys_id: str,
457
+ snow_filename: str,
458
+ client: Optional[AsyncClient] = None,
459
+ ) -> Optional[str]:
460
+ async def _exec(_client: AsyncClient) -> Optional[str]:
461
+ query_params = {
462
+ "table_name": table_name,
463
+ "table_sys_id": table_row_sys_id,
464
+ "file_name": snow_filename,
465
+ }
466
+ async with __sem:
467
+ r = await _client.get(
468
+ "api/now/attachment",
469
+ params=query_params,
470
+ )
471
+
472
+ r.raise_for_status()
473
+ await check_for_202(r=r)
474
+ json_data = r.json()["result"]
475
+ if len(json_data) > 0:
476
+ return json_data[0].get("sys_id")
477
+
478
+ return None
479
+
480
+ if not client:
481
+ async with http_client() as _client:
482
+ return await _exec(_client)
483
+ else:
484
+ return await _exec(client)
485
+
486
+
487
+ @async_retry_http(
488
+ max_attempts=get_config().http_retry_max_count,
489
+ delay_seconds=1,
490
+ status_codes_to_ignore=[400, 401, 403, 404],
491
+ )
492
+ async def get_attachments(
493
+ table_name: str,
494
+ table_row_sys_id: str,
495
+ client: Optional[AsyncClient] = None,
496
+ ) -> List[Dict[str, Any]]:
497
+ async def _exec(_client: AsyncClient) -> List[Dict[str, Any]]:
498
+ query = f'table_name="{table_name}"^table_sys_id="{table_row_sys_id}"'
499
+ async with __sem:
500
+ r = await _client.get(
501
+ "api/now/attachment",
502
+ params=f"sysparm_query={query}",
503
+ )
504
+
505
+ r.raise_for_status()
506
+ await check_for_202(r=r)
507
+ return r.json()["result"]
508
+
509
+ if not client:
510
+ async with http_client() as _client:
511
+ return await _exec(_client)
512
+ else:
513
+ return await _exec(client)
514
+
515
+
516
+ @async_retry_http(
517
+ max_attempts=get_config().get_config().http_retry_max_count,
518
+ delay_seconds=1,
519
+ status_codes_to_ignore=[400, 401, 403, 404],
520
+ )
521
+ async def delete_attachment(
522
+ attachment_id: str, client: Optional[AsyncClient] = None
523
+ ) -> None:
524
+ async def _exec(_client: AsyncClient) -> None:
525
+ r = await _client.delete(f"api/now/attachment/{attachment_id}")
526
+
527
+ r.raise_for_status()
528
+ await check_for_202(r=r)
529
+
530
+ if not client:
531
+ async with http_client() as _client:
532
+ await _exec(_client)
533
+ else:
534
+ await _exec(client)
535
+
536
+
537
+ @async_retry_http(
538
+ max_attempts=3,
539
+ delay_seconds=1,
540
+ status_codes_to_ignore=[400, 401, 403, 404],
541
+ )
542
+ async def upload_artifact(
543
+ table_name: str,
544
+ table_row_sys_id: str,
545
+ filepath: Path,
546
+ snow_filename: str,
547
+ client: Optional[AsyncClient] = None,
548
+ ) -> Tuple[str, str]:
549
+ async def _exec(_client: AsyncClient) -> Tuple[str, str]:
550
+ generic_type = "application/octet-stream"
551
+ headers = {"Content-Type": generic_type, "Accept": "application/json"}
552
+ query_params = {
553
+ "table_name": table_name,
554
+ "table_sys_id": table_row_sys_id,
555
+ "file_name": snow_filename,
556
+ }
557
+ async with __sem:
558
+ with open(filepath, mode="rb") as file:
559
+ r = await _client.post(
560
+ "api/now/attachment/file",
561
+ headers=headers,
562
+ params=query_params,
563
+ content=file.read(),
564
+ )
565
+
566
+ if not r.is_success:
567
+ logger.warning(r.text)
568
+
569
+ r.raise_for_status()
570
+ await check_for_202(r=r)
571
+
572
+ json_data = r.json()["result"]
573
+ filename = json_data.get("file_name")
574
+ attachment_id = json_data.get("sys_id")
575
+
576
+ return f"{attachment_id}", filename
577
+
578
+ if not client:
579
+ async with http_client() as _client:
580
+ return await _exec(_client)
581
+ else:
582
+ return await _exec(client)
583
+
584
+
585
+ @async_retry_http(
586
+ max_attempts=get_config().http_retry_max_count,
587
+ delay_seconds=1,
588
+ status_codes_to_ignore=[400, 401, 403, 404],
589
+ )
590
+ async def download_artifact(
591
+ attachment_id: str, filename: str, client: Optional[AsyncClient] = None
592
+ ) -> Tuple[str, str]:
593
+ config = get_config()
594
+
595
+ async def _exec(_client: AsyncClient) -> Tuple[str, str]:
596
+ r = await _client.get(f"api/now/attachment/{attachment_id}/file")
597
+
598
+ if not r.is_success:
599
+ logger.warning(r.text)
600
+
601
+ r.raise_for_status()
602
+ await check_for_202(r=r)
603
+
604
+ with open(config.output_path / filename, mode="wb") as file:
605
+ for chunk in r.iter_bytes():
606
+ file.write(chunk)
607
+
608
+ return config.output_path / filename
609
+
610
+ if not client:
611
+ async with http_client() as _client:
612
+ return await _exec(_client)
613
+ else:
614
+ return await _exec(client)
615
+
616
+
617
+ def get_attachment_url(attachment_id: str) -> str:
618
+ config = get_config()
619
+ return f"{config.sn_api_url}/nav_to.do?uri=sys_attachment.do?sys_id={attachment_id}"
620
+
621
+
622
+ if __name__ == "__main__":
623
+ async def __test() -> None:
624
+ _data = await get_table_rows("sn_irm_cont_auth_pack", sysparm_limit=1)
625
+ logger.info(_data)
626
+
627
+
628
+ asyncio.run(__test())
@@ -0,0 +1,188 @@
1
+ import asyncio
2
+ from datetime import datetime, UTC
3
+ from typing import List, Dict, Optional
4
+
5
+ from loguru import logger
6
+
7
+ from pjdev_sn_sdk import sn_api_service, control_parsing_service
8
+ from pjdev_sn_sdk.config_service import get_config
9
+ from pjdev_sn_sdk.models import snow_tables, SnowAuthPack
10
+
11
+
12
+ async def watch_for_queued_task_to_complete(auth_pack_sys_id: str) -> None:
13
+ table_name = snow_tables.item_generation_action_event_queue_table
14
+ fields = ["sys_id", "state", "source.name", "action", "table", "sys_created_on"]
15
+ current_state = None
16
+ max_retries = 1080 # 5400 total seconds (1.5 hours) in 5-second intervals
17
+ retry_count = 0
18
+ utc_timestamp = datetime.now(tz=UTC)
19
+ while current_state != "processed":
20
+ if retry_count >= max_retries:
21
+ raise Exception(
22
+ f"Timed out waiting for task to complete for {auth_pack_sys_id}"
23
+ )
24
+ retry_count += 1
25
+ await asyncio.sleep(5)
26
+ response, count = await sn_api_service.get_table_rows(
27
+ table_name=table_name,
28
+ query=f"source={auth_pack_sys_id}^ORDERBYDESCsys_created_on",
29
+ fields=",".join(fields),
30
+ )
31
+ if len(response) == 0:
32
+ continue
33
+ data = response[0]
34
+ created_on_str = data["sys_created_on"] + " +0000"
35
+ created_on_dt = datetime.strptime(created_on_str, "%Y-%m-%d %H:%M:%S %z")
36
+ current_state = data["state"]
37
+ # ensure we are not looking at a job that was processed by a previous run
38
+ if (
39
+ abs((utc_timestamp - created_on_dt).total_seconds()) > 60 * 3
40
+ and current_state == "processed"
41
+ ):
42
+ current_state = ""
43
+ utc_timestamp = datetime.now(tz=UTC)
44
+ continue
45
+ auth_pack_name = data["source.name"]
46
+ logger.info(f"State of task for {auth_pack_name}: {current_state}")
47
+
48
+
49
+ async def mark_control_ids_as_common(
50
+ auth_pack_sys_id: str, control_ids: List[str]
51
+ ) -> None:
52
+ baseline_controls, baseline_count = await sn_api_service.get_table_rows(
53
+ table_name=snow_tables.baseline_control_table_name,
54
+ query=f"authorization_package.sys_id={auth_pack_sys_id}",
55
+ fields="sys_id,control_objective.reference,common_control,implementation_status,inherited_from,justification",
56
+ )
57
+
58
+ for control_id in control_ids:
59
+ b_control = next(
60
+ (
61
+ b
62
+ for b in baseline_controls
63
+ if control_parsing_service.extract_control(
64
+ b["control_objective.reference"]
65
+ )
66
+ == control_parsing_service.extract_control(control_id)
67
+ ),
68
+ None,
69
+ )
70
+ if not b_control:
71
+ logger.warning(f"{control_id} not found in baseline")
72
+ continue
73
+
74
+ updated_sn_row = await sn_api_service.patch_table_row(
75
+ table_name=snow_tables.baseline_control_table_name,
76
+ payload={
77
+ "implementation_status": "need_to_implement",
78
+ "common_control": True,
79
+ },
80
+ fields=[
81
+ "implementation_status",
82
+ "sys_id",
83
+ "common_control",
84
+ "control_objective.reference",
85
+ ],
86
+ sys_id=b_control["sys_id"],
87
+ )
88
+
89
+ if (
90
+ updated_sn_row["implementation_status"] != "need_to_implement"
91
+ or updated_sn_row["common_control"] != "true"
92
+ ):
93
+ logger.error(f"{control_id} was not marked as n/a")
94
+
95
+
96
+ async def associate_controls_to_poam(
97
+ auth_pack_sys_id: str,
98
+ poam_sys_id: str,
99
+ display_poam_id: str,
100
+ control_ids: List[str],
101
+ ) -> None:
102
+ controls, count = await sn_api_service.get_table_rows(
103
+ table_name=snow_tables.control_table_name,
104
+ paginate=True,
105
+ fields="sys_id,content.reference",
106
+ query=f"authorization_package.sys_id={auth_pack_sys_id}",
107
+ )
108
+
109
+ for control_id in control_ids:
110
+ sn_c = next(
111
+ (
112
+ c
113
+ for c in controls
114
+ if c["content.reference"].lower() == control_id.lower()
115
+ ),
116
+ None,
117
+ )
118
+
119
+ if not sn_c:
120
+ logger.warning(
121
+ f"Could not find control {control_id} in auth pack {auth_pack_sys_id}"
122
+ )
123
+ continue
124
+
125
+ logger.info(
126
+ f"Associating control {control_id} to poam {display_poam_id} ({poam_sys_id})"
127
+ )
128
+ payload = {"sn_grc_issue": poam_sys_id, "sn_grc_item": sn_c["sys_id"]}
129
+ existing_link, link_count = await sn_api_service.get_table_rows(
130
+ table_name=snow_tables.poam_control_m2m_table_name,
131
+ query=f"sn_grc_issue.sys_id={poam_sys_id}^sn_grc_item.sys_id={sn_c['sys_id']}",
132
+ )
133
+
134
+ if link_count == 0:
135
+ await sn_api_service.add_table_row(
136
+ table_name=snow_tables.poam_control_m2m_table_name,
137
+ payload=payload,
138
+ )
139
+ else:
140
+ logger.info(
141
+ f"Poam Control Link already exists for {display_poam_id} - {control_id}"
142
+ )
143
+
144
+ logger.info(f"Finished associating controls for {display_poam_id}")
145
+
146
+
147
+ def save_existing_auth_pack_data(sn_auth_pack: Dict) -> SnowAuthPack:
148
+ auth_pack = SnowAuthPack.model_validate(sn_auth_pack)
149
+ config = get_config()
150
+
151
+ with open(
152
+ config.output_path / f"existing_data_{auth_pack.number}.json",
153
+ "w",
154
+ encoding="utf-8",
155
+ ) as f:
156
+ f.write(auth_pack.model_dump_json(indent=2))
157
+
158
+ return auth_pack
159
+
160
+
161
+ def read_existing_auth_pack_data(sn_auth_pack_number: str) -> Optional[SnowAuthPack]:
162
+ config = get_config()
163
+ filepath = config.output_path / f"existing_data_{sn_auth_pack_number}.json"
164
+ if not filepath.exists():
165
+ return None
166
+ with open(filepath, "r", encoding="utf-8") as f:
167
+ json_str = f.read()
168
+
169
+ auth_pack = SnowAuthPack.model_validate_json(json_str)
170
+
171
+ return auth_pack
172
+
173
+
174
+ if __name__ == "__main__":
175
+ ap_number = "testing"
176
+ test = {
177
+ "sys_id": "test sys id",
178
+ "name": "test",
179
+ "number": ap_number,
180
+ "step": 9,
181
+ "authorization_comments": "auth_pack.authorization_comments",
182
+ "next_authorization_date": "auth_pack.next_authorization_date",
183
+ "authorization_date": "auth_pack.authorization_date",
184
+ "authorization_decision": "auth_pack.authorization_decision",
185
+ "ongoing_authorization": "auth_pack.ongoing_authorization",
186
+ }
187
+ save_existing_auth_pack_data(test)
188
+ print(read_existing_auth_pack_data(ap_number))
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: pjdev-sn-sdk
3
+ Version: 4.6.4
4
+ Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-pydantic/README.md
5
+ Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
+ Project-URL: Source, https://gitlab.purplejay.io/keystone/python
7
+ Author-email: Purple Jay LLC <developers@purplejay.io>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.txt
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: Implementation :: CPython
14
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: httpx
17
+ Requires-Dist: loguru
18
+ Requires-Dist: pydantic-settings>=2.13.1
19
+ Requires-Dist: pydantic>=2.12.5
20
+ Provides-Extra: dev
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Provides-Extra: test
23
+ Requires-Dist: coverage; extra == 'test'
24
+ Requires-Dist: pytest; extra == 'test'
25
+ Requires-Dist: pytz; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pjdev-sn-sdk
29
+
30
+ [![PyPI - Version](https://img.shields.io/pypi/v/pjdev-sn-sdk.svg)](https://pypi.org/project/pjdev-sn-sdk)
31
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-sn-sdk.svg)](https://pypi.org/project/pjdev-sn-sdk)
32
+
33
+ -----
34
+
35
+ ## Table of Contents
36
+
37
+ - [Installation](#installation)
38
+ - [License](#license)
39
+
40
+ ## Installation
41
+
42
+ ```console
43
+ pip install pjdev-sn-sdk
44
+ ```
45
+
46
+ ## License
47
+
48
+ `pjdev-sn-sdk` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,12 @@
1
+ pjdev_sn_sdk/__about__.py,sha256=opYe2fsq9dxRdB87CIMtUyN0XYxM8oLw0JewyGRCu-8,129
2
+ pjdev_sn_sdk/__init__.py,sha256=2UETghiXy-pnzHKsOef2J4B_g7SSzVWGI-dVTed7FYo,107
3
+ pjdev_sn_sdk/api_utilities.py,sha256=XIy_cJWswaeacjEU5iHq0eD6w4N7GXklUQewEgmESRk,5294
4
+ pjdev_sn_sdk/config_service.py,sha256=ZHTlyLCgvXZaLX7NWyt0Tvn6WsGBk3dwwkbeZXRRYRw,406
5
+ pjdev_sn_sdk/control_parsing_service.py,sha256=fPi9QXhsMQT57XAhqtGyoxB-1-CpAD2jHUPd04KzDHM,3448
6
+ pjdev_sn_sdk/models.py,sha256=o3kOKU0LORjCbuybhq7tc1IR0qZoCyeQzISn7SC1-kQ,4177
7
+ pjdev_sn_sdk/sn_api_service.py,sha256=A_un_RsALpkVuxCWnquv_3mApjfucLUzyOj1wPpx7lI,18617
8
+ pjdev_sn_sdk/sn_api_utilities.py,sha256=Cy6pTEvzWgw2EQi17Y6LE7Yo7fcnDrqs2SIBerLEFP0,6570
9
+ pjdev_sn_sdk-4.6.4.dist-info/METADATA,sha256=5vGmsx4Vyim9aniWxJalNcQn1oQ8KoiA7-73qZi66GU,1567
10
+ pjdev_sn_sdk-4.6.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ pjdev_sn_sdk-4.6.4.dist-info/licenses/LICENSE.txt,sha256=lsBCQyCWJBNssHxGJFROYAUl6CVjKJ52uHHydxCxr4c,1099
12
+ pjdev_sn_sdk-4.6.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Chris O'Neill <chris@purplejay.io>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.