kst 1.0.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.
- kst/__about__.py +2 -0
- kst/__init__.py +4 -0
- kst/api/__init__.py +25 -0
- kst/api/client.py +195 -0
- kst/api/payload.py +64 -0
- kst/api/profiles.py +230 -0
- kst/api/resource_base.py +57 -0
- kst/api/scripts.py +219 -0
- kst/api/self_service.py +35 -0
- kst/cli/__init__.py +101 -0
- kst/cli/_resources/gitignore.txt +6 -0
- kst/cli/_resources/new_repo_readme.md +425 -0
- kst/cli/common.py +237 -0
- kst/cli/new.py +79 -0
- kst/cli/profile/__init__.py +40 -0
- kst/cli/profile/common.py +59 -0
- kst/cli/profile/delete.py +216 -0
- kst/cli/profile/list.py +117 -0
- kst/cli/profile/new.py +157 -0
- kst/cli/profile/pull.py +184 -0
- kst/cli/profile/push.py +184 -0
- kst/cli/profile/show.py +78 -0
- kst/cli/profile/sync.py +177 -0
- kst/cli/script/__init__.py +40 -0
- kst/cli/script/common.py +47 -0
- kst/cli/script/delete.py +216 -0
- kst/cli/script/list.py +117 -0
- kst/cli/script/new.py +231 -0
- kst/cli/script/pull.py +184 -0
- kst/cli/script/push.py +183 -0
- kst/cli/script/show.py +98 -0
- kst/cli/script/sync.py +178 -0
- kst/cli/utility.py +1439 -0
- kst/console.py +181 -0
- kst/diff.py +64 -0
- kst/exceptions.py +57 -0
- kst/git.py +270 -0
- kst/repository/__init__.py +40 -0
- kst/repository/content.py +146 -0
- kst/repository/custom_profile.py +291 -0
- kst/repository/custom_script.py +382 -0
- kst/repository/info.py +215 -0
- kst/repository/member_base.py +183 -0
- kst/repository/repository.py +130 -0
- kst/utils.py +33 -0
- kst-1.0.0.dist-info/METADATA +512 -0
- kst-1.0.0.dist-info/RECORD +50 -0
- kst-1.0.0.dist-info/WHEEL +4 -0
- kst-1.0.0.dist-info/entry_points.txt +2 -0
- kst-1.0.0.dist-info/licenses/LICENSE +17 -0
kst/__about__.py
ADDED
kst/__init__.py
ADDED
kst/api/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .client import ApiClient, ApiConfig
|
|
2
|
+
from .payload import (
|
|
3
|
+
ApiPayloadType,
|
|
4
|
+
CustomProfilePayload,
|
|
5
|
+
CustomScriptPayload,
|
|
6
|
+
PayloadList,
|
|
7
|
+
SelfServiceCategoryPayload,
|
|
8
|
+
)
|
|
9
|
+
from .profiles import CustomProfilesResource
|
|
10
|
+
from .scripts import CustomScriptsResource, ExecutionFrequency
|
|
11
|
+
from .self_service import SelfServiceCategoriesResource
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ApiClient",
|
|
15
|
+
"ApiConfig",
|
|
16
|
+
"ApiPayloadType",
|
|
17
|
+
"CustomProfilePayload",
|
|
18
|
+
"CustomProfilesResource",
|
|
19
|
+
"CustomScriptPayload",
|
|
20
|
+
"CustomScriptsResource",
|
|
21
|
+
"ExecutionFrequency",
|
|
22
|
+
"PayloadList",
|
|
23
|
+
"SelfServiceCategoriesResource",
|
|
24
|
+
"SelfServiceCategoryPayload",
|
|
25
|
+
]
|
kst/api/client.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urljoin, urlparse
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from kst.console import OutputConsole
|
|
11
|
+
from kst.exceptions import ApiClientError
|
|
12
|
+
|
|
13
|
+
console = OutputConsole(logging.getLogger(__name__))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiConfig(BaseModel):
|
|
17
|
+
"""A Container for API configuration values.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
url (HttpUrl): API URL for the Kandji tenant. Must use the https:// schema.
|
|
21
|
+
api_token (str): API authentication token for the Kandji tenant.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
url: str = Field(alias="tenant_url")
|
|
26
|
+
api_token: str = Field(repr=False)
|
|
27
|
+
|
|
28
|
+
@field_validator("url", mode="before")
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_url(cls, v) -> str:
|
|
31
|
+
"""Ensure the url is using https and is a valid Kandji API URL."""
|
|
32
|
+
# Ensure the URL is a string with a https schema
|
|
33
|
+
|
|
34
|
+
if isinstance(v, str) and (parsed_url := urlparse(v)).scheme in ("", "http"):
|
|
35
|
+
if parsed_url.netloc == "":
|
|
36
|
+
# handle misclassified netloc: https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse
|
|
37
|
+
v = parsed_url._replace(scheme="https", netloc=parsed_url.path, path="").geturl()
|
|
38
|
+
else:
|
|
39
|
+
v = parsed_url._replace(scheme="https").geturl()
|
|
40
|
+
|
|
41
|
+
v = v.rstrip("/") # Normalize without trailing slash
|
|
42
|
+
|
|
43
|
+
if not re.fullmatch(r"https://[A-Za-z0-9-]+\.api(\.eu)?\.kandji\.io", v):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"The Tenant URL must be a valid Kandji API URL. "
|
|
46
|
+
"Please ensure the URL is in the format https://<tenant>.api.kandji.io or https://<tenant>.api.eu.kandji.io."
|
|
47
|
+
)
|
|
48
|
+
return v
|
|
49
|
+
|
|
50
|
+
@field_validator("api_token", mode="after")
|
|
51
|
+
@classmethod
|
|
52
|
+
def validate_token(cls, v: str) -> str:
|
|
53
|
+
"""Ensure the api_token is a uuid4 string."""
|
|
54
|
+
if not re.fullmatch(r"[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}", v):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"The API token must be a valid UUID4 string. "
|
|
57
|
+
"Please ensure the token is in the format 12345678-1234-5678-1234-123456789012."
|
|
58
|
+
)
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ApiClient:
|
|
63
|
+
"""Basic API client for interacting with the Kandji API.
|
|
64
|
+
|
|
65
|
+
The ApiClient class is a thin wrapper around a requests.Session which handles converting
|
|
66
|
+
resource paths to fully resolved API resources. It also manages passing credential's with
|
|
67
|
+
requests.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
session (requests.Session): The underlying session used by the client.
|
|
71
|
+
|
|
72
|
+
Methods:
|
|
73
|
+
request: Make an generic HTTP request
|
|
74
|
+
get: Make a GET HTTP request
|
|
75
|
+
patch: Make a PATCH HTTP request
|
|
76
|
+
post: Make a POST HTTP request
|
|
77
|
+
delete: Make a DELETE HTTP request
|
|
78
|
+
close: Close the internal session object.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, config: ApiConfig) -> None:
|
|
82
|
+
self._config = config
|
|
83
|
+
self._session = requests.Session()
|
|
84
|
+
self._update_header()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def session(self) -> requests.Session:
|
|
88
|
+
"""Get the session object for the client.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
requests.Session: The internal session object
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ApiClientError: Raised when the session is not open.
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
if self._session is None:
|
|
99
|
+
raise ApiClientError("No open session available.")
|
|
100
|
+
|
|
101
|
+
return self._session
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
"""Close the internal session object."""
|
|
105
|
+
if self._session is not None:
|
|
106
|
+
self.session.close()
|
|
107
|
+
self._session = None
|
|
108
|
+
|
|
109
|
+
def _update_header(self):
|
|
110
|
+
"""Update the session headers with the API token."""
|
|
111
|
+
self.session.headers.update(
|
|
112
|
+
{
|
|
113
|
+
"Authorization": f"Bearer {self._config.api_token}",
|
|
114
|
+
"Accept": "application/json",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _make_url(self, path: str):
|
|
119
|
+
"""Convert a relative path to a fully qualified URL."""
|
|
120
|
+
return urljoin(self._config.url, path)
|
|
121
|
+
|
|
122
|
+
def request(self, method: str, url: str, *args, **kwargs) -> requests.Response:
|
|
123
|
+
"""Make a generic HTTP request.
|
|
124
|
+
|
|
125
|
+
Handles logging and ensures the source query parameter is included.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
requests.Response: The response object from the request
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
requests.ConnectionError: Raised when the API connection fails
|
|
132
|
+
requests.HTTPError: Raised when the HTTP request returns an unsuccessful status code
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
console.debug(f"Making {method} request to {url}")
|
|
138
|
+
|
|
139
|
+
# Add the source=kst param to all requests
|
|
140
|
+
kwargs["params"] = kwargs.get("params", {}) | {"source": "kst"}
|
|
141
|
+
|
|
142
|
+
response = self.session.request(method, url, *args, **kwargs)
|
|
143
|
+
|
|
144
|
+
console.debug(f"Response status code: {response.status_code}")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
headers = "\n" + json.dumps(dict(response.headers), indent=2)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
headers = response.headers
|
|
150
|
+
console.debug(f"Response headers: {headers}")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
content = "\n" + json.dumps(response.json(), indent=2)
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
content = response.text
|
|
156
|
+
console.debug(f"Response content: {content}")
|
|
157
|
+
|
|
158
|
+
response.raise_for_status()
|
|
159
|
+
except requests.ConnectionError as error:
|
|
160
|
+
console.error(f"Connection error occurred: {error}")
|
|
161
|
+
raise
|
|
162
|
+
except requests.HTTPError as error:
|
|
163
|
+
console.error(f"HTTP error occurred: {error.response.status_code}")
|
|
164
|
+
console.error(f"Response content: {error.response.text}")
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
return response
|
|
168
|
+
|
|
169
|
+
def get(self, path: str) -> requests.Response:
|
|
170
|
+
"""Make a GET HTTP request to the resolved API endpoint at path."""
|
|
171
|
+
return self.request("GET", self._make_url(path))
|
|
172
|
+
|
|
173
|
+
def patch(
|
|
174
|
+
self,
|
|
175
|
+
path: str,
|
|
176
|
+
data: dict | None = None,
|
|
177
|
+
json: dict | None = None,
|
|
178
|
+
files: list[tuple[str, tuple[str, io.BufferedReader, str]]] | None = None,
|
|
179
|
+
) -> requests.Response:
|
|
180
|
+
"""Make a PATCH HTTP request to the resolved API endpoint at path."""
|
|
181
|
+
return self.request("PATCH", self._make_url(path), data=data, json=json, files=files)
|
|
182
|
+
|
|
183
|
+
def post(
|
|
184
|
+
self,
|
|
185
|
+
path: str,
|
|
186
|
+
data: dict | None = None,
|
|
187
|
+
json: dict | None = None,
|
|
188
|
+
files: list[tuple[str, tuple[str, io.BufferedReader, str]]] | None = None,
|
|
189
|
+
) -> requests.Response:
|
|
190
|
+
"""Make a POST HTTP request to the resolved API endpoint at path."""
|
|
191
|
+
return self.request("POST", self._make_url(path), data=data, json=json, files=files)
|
|
192
|
+
|
|
193
|
+
def delete(self, path: str) -> requests.Response:
|
|
194
|
+
"""Make a DELETE HTTP request to the resolved API endpoint at path."""
|
|
195
|
+
return self.request("DELETE", self._make_url(path))
|
kst/api/payload.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Annotated, Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, field_validator
|
|
4
|
+
|
|
5
|
+
ApiPayloadType = TypeVar("ApiPayloadType", "CustomProfilePayload", "CustomScriptPayload")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomProfilePayload(BaseModel):
|
|
9
|
+
"""Payload model for custom profiles API endpoints."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
@field_validator("profile", mode="after")
|
|
14
|
+
@classmethod
|
|
15
|
+
def tabs_to_spaces(cls, v: str) -> str:
|
|
16
|
+
return v.expandtabs(tabsize=4)
|
|
17
|
+
|
|
18
|
+
id: Annotated[str, AfterValidator(lambda value: value.lower())]
|
|
19
|
+
name: str
|
|
20
|
+
active: bool
|
|
21
|
+
profile: str
|
|
22
|
+
mdm_identifier: str
|
|
23
|
+
created_at: str
|
|
24
|
+
updated_at: str
|
|
25
|
+
runs_on_mac: bool = False
|
|
26
|
+
runs_on_iphone: bool = False
|
|
27
|
+
runs_on_ipad: bool = False
|
|
28
|
+
runs_on_tv: bool = False
|
|
29
|
+
runs_on_vision: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CustomScriptPayload(BaseModel):
|
|
33
|
+
"""Payload model for custom script API endpoints."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
id: Annotated[str, AfterValidator(lambda value: value.lower())]
|
|
38
|
+
name: str
|
|
39
|
+
active: bool
|
|
40
|
+
execution_frequency: str
|
|
41
|
+
restart: bool
|
|
42
|
+
script: str
|
|
43
|
+
remediation_script: str
|
|
44
|
+
created_at: str
|
|
45
|
+
updated_at: str
|
|
46
|
+
show_in_self_service: bool
|
|
47
|
+
self_service_category_id: str | None = None
|
|
48
|
+
self_service_recommended: bool | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SelfServiceCategoryPayload(BaseModel):
|
|
52
|
+
"""Payload model for self-service categories API endpoints."""
|
|
53
|
+
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PayloadList(BaseModel, Generic[ApiPayloadType]):
|
|
59
|
+
"""Payload model for the syncable list endpoints."""
|
|
60
|
+
|
|
61
|
+
count: int = 0
|
|
62
|
+
next: str | None = None
|
|
63
|
+
previous: str | None = None
|
|
64
|
+
results: list[ApiPayloadType] = []
|
kst/api/profiles.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from io import BufferedReader
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .payload import CustomProfilePayload, PayloadList
|
|
5
|
+
from .resource_base import ResourceBase
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomProfilesResource(ResourceBase):
|
|
9
|
+
"""An API client wrapper for interacting with the Custom Profiles endpoint.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
client (ApiClient): An ApiClient object with an open Session
|
|
13
|
+
|
|
14
|
+
Methods:
|
|
15
|
+
list: Retrieve a list of all custom profile(s)
|
|
16
|
+
get: Retrieve a single custom profile by id
|
|
17
|
+
create: Create a new custom profile
|
|
18
|
+
update: Update an existing custom profile by id
|
|
19
|
+
delete: Delete an existing custom profile by id
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_path = "/api/v1/library/custom-profiles"
|
|
24
|
+
|
|
25
|
+
def list(self) -> PayloadList[CustomProfilePayload]:
|
|
26
|
+
"""Retrieve a list of all custom profile(s).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
PayloadList: An object containing all combined results
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ApiClientError: Raised if a ApiClient has not been opened
|
|
33
|
+
HTTPError: Raised when the HTTP request returns an unsuccessful status code
|
|
34
|
+
ConnectionError: Raised when the API connection fails
|
|
35
|
+
ValidationError: Raised when the response does not match the expected schema
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
all_results = PayloadList[CustomProfilePayload]()
|
|
40
|
+
next_page = self._path
|
|
41
|
+
while next_page:
|
|
42
|
+
response = self.client.get(next_page)
|
|
43
|
+
|
|
44
|
+
# Parse bytes content to CustomProfilePayloadList or raise ValidationError
|
|
45
|
+
profile_list = PayloadList.model_validate_json(response.content)
|
|
46
|
+
|
|
47
|
+
all_results.count = profile_list.count
|
|
48
|
+
all_results.results.extend(profile_list.results)
|
|
49
|
+
|
|
50
|
+
next_page = profile_list.next
|
|
51
|
+
|
|
52
|
+
return all_results
|
|
53
|
+
|
|
54
|
+
def get(self, id: str) -> CustomProfilePayload:
|
|
55
|
+
"""Retrieve details about a custom profile.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
id (str): The library item id of the profile to retrieve
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
CustomProfilePayload: A parsed object from the response
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ApiClientError: Raised if a ApiClient has not been opened
|
|
65
|
+
HTTPError: Raised when the HTTP request returns an unsuccessful status code
|
|
66
|
+
ConnectionError: Raised when the API connection fails
|
|
67
|
+
ValidationError: Raised when the response does not match the expected schema
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
response = self.client.get(f"{self._path}/{id}")
|
|
72
|
+
return CustomProfilePayload.model_validate_json(response.content)
|
|
73
|
+
|
|
74
|
+
def create(
|
|
75
|
+
self,
|
|
76
|
+
name: str,
|
|
77
|
+
file: Path | BufferedReader,
|
|
78
|
+
active: bool = False,
|
|
79
|
+
runs_on_mac: bool | None = None,
|
|
80
|
+
runs_on_iphone: bool | None = None,
|
|
81
|
+
runs_on_ipad: bool | None = None,
|
|
82
|
+
runs_on_tv: bool | None = None,
|
|
83
|
+
runs_on_vision: bool | None = None,
|
|
84
|
+
) -> CustomProfilePayload:
|
|
85
|
+
"""Create custom profiles in Kandji.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
name (str): The name for the new profile
|
|
89
|
+
file (Path | BufferedReader): Path or open Buffer to the mobileconfig file to upload
|
|
90
|
+
active (bool): Whether the profile is active or not
|
|
91
|
+
runs_on_mac (bool): Whether the profile runs on macOS
|
|
92
|
+
runs_on_iphone (bool): Whether the profile runs on iPhone
|
|
93
|
+
runs_on_ipad (bool): Whether the profile runs on iPad
|
|
94
|
+
runs_on_tv (bool): Whether the profile runs on Apple TV
|
|
95
|
+
runs_on_vision (bool): Whether the profile runs on Apple TV
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
CustomProfilePayload: A parsed object from the response
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: Raised when invalid parameters are passed
|
|
102
|
+
ApiClientError: Raised if a ApiClient has not been opened
|
|
103
|
+
HTTPError: Raised when the HTTP request returns an unsuccessful status code
|
|
104
|
+
ConnectionError: Raised when the API connection fails
|
|
105
|
+
ValidationError: Raised when the response does not match the expected schema
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
runs_on_args = {
|
|
110
|
+
"runs_on_mac": runs_on_mac,
|
|
111
|
+
"runs_on_iphone": runs_on_iphone,
|
|
112
|
+
"runs_on_ipad": runs_on_ipad,
|
|
113
|
+
"runs_on_tv": runs_on_tv,
|
|
114
|
+
"runs_on_vision": runs_on_vision,
|
|
115
|
+
}
|
|
116
|
+
if not any(runs_on_args.values()):
|
|
117
|
+
raise ValueError("At least one runs_on_* argument must be True.")
|
|
118
|
+
|
|
119
|
+
close_file = False
|
|
120
|
+
if isinstance(file, Path):
|
|
121
|
+
if not file.is_file():
|
|
122
|
+
raise FileNotFoundError(f"The file {file} does not exist or is not readable")
|
|
123
|
+
file_obj = file.open("rb")
|
|
124
|
+
file_name = file.name
|
|
125
|
+
close_file = True
|
|
126
|
+
elif isinstance(file, BufferedReader):
|
|
127
|
+
file_obj = file
|
|
128
|
+
file_name = f"{name}.mobileconfig"
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError("Invalid file type provided. Must be a Path or BufferedReader object.")
|
|
131
|
+
|
|
132
|
+
files = [("file", (file_name, file_obj, "application/octet-stream"))]
|
|
133
|
+
payload = {"name": name, "active": active} | {k: v for k, v in runs_on_args.items() if v is not None}
|
|
134
|
+
response = self.client.post(self._path, data=payload, files=files)
|
|
135
|
+
|
|
136
|
+
if close_file:
|
|
137
|
+
file_obj.close()
|
|
138
|
+
|
|
139
|
+
return CustomProfilePayload.model_validate_json(response.content)
|
|
140
|
+
|
|
141
|
+
def update(
|
|
142
|
+
self,
|
|
143
|
+
id: str,
|
|
144
|
+
name: str | None = None,
|
|
145
|
+
file: Path | BufferedReader | None = None,
|
|
146
|
+
active: bool = False,
|
|
147
|
+
runs_on_mac: bool | None = None,
|
|
148
|
+
runs_on_iphone: bool | None = None,
|
|
149
|
+
runs_on_ipad: bool | None = None,
|
|
150
|
+
runs_on_tv: bool | None = None,
|
|
151
|
+
runs_on_vision: bool | None = None,
|
|
152
|
+
) -> CustomProfilePayload:
|
|
153
|
+
"""Update specified custom profile in Kandji with contents from provided .mobileconfig file in the specified directory.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
id (str): The id of the profile to update
|
|
157
|
+
name (str): The name for the profile
|
|
158
|
+
file (Path | BufferedReader): Path or open Buffer to the mobileconfig file to upload
|
|
159
|
+
active (bool): Whether the profile is active or not
|
|
160
|
+
runs_on_mac (bool): Whether the profile runs on macOS
|
|
161
|
+
runs_on_iphone (bool): Whether the profile runs on iPhone
|
|
162
|
+
runs_on_ipad (bool): Whether the profile runs on iPad
|
|
163
|
+
runs_on_tv (bool): Whether the profile runs on Apple TV
|
|
164
|
+
runs_on_vision (bool): Whether the profile runs on Apple TV
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
CustomProfilePayload: A parsed object from the response
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: Raised when invalid parameters are passed
|
|
171
|
+
ApiClientError: Raised if a ApiClient has not been opened
|
|
172
|
+
HTTPError: Raised when the HTTP request returns an unsuccessful status code
|
|
173
|
+
ConnectionError: Raised when the API connection fails
|
|
174
|
+
ValidationError: Raised when the response does not match the expected schema
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
payload = {
|
|
179
|
+
k: v
|
|
180
|
+
for k, v in {
|
|
181
|
+
"name": name,
|
|
182
|
+
"active": active,
|
|
183
|
+
"runs_on_mac": runs_on_mac,
|
|
184
|
+
"runs_on_iphone": runs_on_iphone,
|
|
185
|
+
"runs_on_ipad": runs_on_ipad,
|
|
186
|
+
"runs_on_tv": runs_on_tv,
|
|
187
|
+
"runs_on_vision": runs_on_vision,
|
|
188
|
+
}.items()
|
|
189
|
+
if v is not None
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
close_file = False
|
|
193
|
+
if file is not None:
|
|
194
|
+
if isinstance(file, Path):
|
|
195
|
+
if not file.is_file():
|
|
196
|
+
raise FileNotFoundError(f"The file {file} does not exist or is not readable")
|
|
197
|
+
file_obj = file.open("rb")
|
|
198
|
+
file_name = file.name
|
|
199
|
+
close_file = True
|
|
200
|
+
elif isinstance(file, BufferedReader):
|
|
201
|
+
file_obj = file
|
|
202
|
+
file_name = f"{name if name is not None else id}.mobileconfig"
|
|
203
|
+
else:
|
|
204
|
+
raise ValueError("Invalid file type provided. Must be a Path or BufferedReader object.")
|
|
205
|
+
files = [("file", (file_name, file_obj, "application/octet-stream"))] if file else None
|
|
206
|
+
else:
|
|
207
|
+
files = None
|
|
208
|
+
file_obj = None
|
|
209
|
+
|
|
210
|
+
response = self.client.patch(f"{self._path}/{id}", data=payload, files=files)
|
|
211
|
+
|
|
212
|
+
if close_file and file_obj is not None:
|
|
213
|
+
file_obj.close()
|
|
214
|
+
|
|
215
|
+
return CustomProfilePayload.model_validate_json(response.content)
|
|
216
|
+
|
|
217
|
+
def delete(self, id: str) -> None:
|
|
218
|
+
"""Delete specified custom profile in Kandji.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
id (str): The id of the profile to delete
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ApiClientError: Raised if a ApiClient has not been opened
|
|
225
|
+
HTTPError: Raised when the HTTP request returns an unsuccessful status code
|
|
226
|
+
ConnectionError: Raised when the API connection fails
|
|
227
|
+
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
self.client.delete(f"{self._path}/{id}")
|
kst/api/resource_base.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from contextlib import AbstractContextManager
|
|
2
|
+
from typing import Protocol, Self, override
|
|
3
|
+
|
|
4
|
+
from kst.exceptions import ApiClientError
|
|
5
|
+
|
|
6
|
+
from .client import ApiClient, ApiConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResourceBase(AbstractContextManager, Protocol):
|
|
10
|
+
"""An API client wrapper for interacting with the resource endpoints
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
client (ApiClient): An ApiClient object with an open Session
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_path: str = ""
|
|
18
|
+
_config: ApiConfig
|
|
19
|
+
_client: ApiClient | None = None
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: ApiConfig) -> None:
|
|
22
|
+
"""Initialize a new CustomProfilesResource obj.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config (ApiConfig): An ApiConfig object with necessary configuration
|
|
26
|
+
path (str): The path to the resource endpoint
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
self._config = config
|
|
30
|
+
self._client: ApiClient | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def client(self) -> ApiClient:
|
|
34
|
+
if self._client is None:
|
|
35
|
+
raise ApiClientError("No open client available.")
|
|
36
|
+
|
|
37
|
+
return self._client
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def __enter__(self) -> Self:
|
|
41
|
+
"""Open a new ApiClient session using with block and return self."""
|
|
42
|
+
self._client = ApiClient(self._config)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
47
|
+
"""Disconnect the ApiClient session when exiting the with block."""
|
|
48
|
+
self.client.close()
|
|
49
|
+
self._client = None
|
|
50
|
+
|
|
51
|
+
def open(self) -> None:
|
|
52
|
+
"""Manually open a new ApiClient session."""
|
|
53
|
+
self.__enter__()
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Manually disconnect the ApiClient session."""
|
|
57
|
+
self.__exit__(None, None, None)
|