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.
Files changed (50) hide show
  1. kst/__about__.py +2 -0
  2. kst/__init__.py +4 -0
  3. kst/api/__init__.py +25 -0
  4. kst/api/client.py +195 -0
  5. kst/api/payload.py +64 -0
  6. kst/api/profiles.py +230 -0
  7. kst/api/resource_base.py +57 -0
  8. kst/api/scripts.py +219 -0
  9. kst/api/self_service.py +35 -0
  10. kst/cli/__init__.py +101 -0
  11. kst/cli/_resources/gitignore.txt +6 -0
  12. kst/cli/_resources/new_repo_readme.md +425 -0
  13. kst/cli/common.py +237 -0
  14. kst/cli/new.py +79 -0
  15. kst/cli/profile/__init__.py +40 -0
  16. kst/cli/profile/common.py +59 -0
  17. kst/cli/profile/delete.py +216 -0
  18. kst/cli/profile/list.py +117 -0
  19. kst/cli/profile/new.py +157 -0
  20. kst/cli/profile/pull.py +184 -0
  21. kst/cli/profile/push.py +184 -0
  22. kst/cli/profile/show.py +78 -0
  23. kst/cli/profile/sync.py +177 -0
  24. kst/cli/script/__init__.py +40 -0
  25. kst/cli/script/common.py +47 -0
  26. kst/cli/script/delete.py +216 -0
  27. kst/cli/script/list.py +117 -0
  28. kst/cli/script/new.py +231 -0
  29. kst/cli/script/pull.py +184 -0
  30. kst/cli/script/push.py +183 -0
  31. kst/cli/script/show.py +98 -0
  32. kst/cli/script/sync.py +178 -0
  33. kst/cli/utility.py +1439 -0
  34. kst/console.py +181 -0
  35. kst/diff.py +64 -0
  36. kst/exceptions.py +57 -0
  37. kst/git.py +270 -0
  38. kst/repository/__init__.py +40 -0
  39. kst/repository/content.py +146 -0
  40. kst/repository/custom_profile.py +291 -0
  41. kst/repository/custom_script.py +382 -0
  42. kst/repository/info.py +215 -0
  43. kst/repository/member_base.py +183 -0
  44. kst/repository/repository.py +130 -0
  45. kst/utils.py +33 -0
  46. kst-1.0.0.dist-info/METADATA +512 -0
  47. kst-1.0.0.dist-info/RECORD +50 -0
  48. kst-1.0.0.dist-info/WHEEL +4 -0
  49. kst-1.0.0.dist-info/entry_points.txt +2 -0
  50. kst-1.0.0.dist-info/licenses/LICENSE +17 -0
kst/__about__.py ADDED
@@ -0,0 +1,2 @@
1
+ APP_NAME = "kst"
2
+ __version__ = "1.0.0"
kst/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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}")
@@ -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)