allspice.allspice
1import json 2import logging 3import sys 4from typing import Any, Dict, List, Mapping, Optional, Union 5 6import requests 7import urllib3 8from frozendict import frozendict 9from requests.adapters import HTTPAdapter 10from urllib3.util import Retry 11 12from .apiobject import Organization, Repository, Team, User 13from .exceptions import ( 14 AlreadyExistsException, 15 APIError, 16 ConflictException, 17 InternalServerException, 18 NotFoundException, 19 NotYetGeneratedException, 20) 21from .ratelimiter import RateLimitedSession 22 23DEFAULT_RETRY = Retry( 24 total=6, 25 backoff_factor=1.0, 26 backoff_max=30.0, 27 backoff_jitter=3.0, 28 # Only retry on 429s (server rate limiting) and 502 (gateway errors). 29 # However, urllib3 additionally retries 413s and 503s that carry a 30 # Retry-After header, which also covers Hub's behaviour of returning 503s 31 # with Retry-After when a generated file is not ready yet. 32 status_forcelist=frozenset({429, 502}), 33 # Retry on all verbs, including verbs that can mutate server state. This is 34 # why we only retry on the two error codes above. 35 allowed_methods=None, 36 # py-allspice has its own mechanism to convert error messages into 37 # exceptions, so we don't want urllib3 to raise on 429s or 502s. 38 raise_on_status=False, 39) 40 41 42class AllSpice: 43 """Object to establish a session with AllSpice Hub.""" 44 45 ADMIN_CREATE_USER = """/admin/users""" 46 GET_USERS_ADMIN = """/admin/users""" 47 ADMIN_REPO_CREATE = """/admin/users/%s/repos""" # <ownername> 48 ALLSPICE_HUB_VERSION = """/version""" 49 GET_USER = """/user""" 50 GET_REPOSITORY = """/repos/{owner}/{name}""" 51 CREATE_ORG = """/admin/users/%s/orgs""" # <username> 52 CREATE_TEAM = """/orgs/%s/teams""" # <orgname> 53 54 def __init__( 55 self, 56 allspice_hub_url="https://hub.allspice.io", 57 token_text=None, 58 auth=None, 59 verify=True, 60 log_level="INFO", 61 ratelimiting=(100, 60), 62 retry: Union[Retry, int, None] = DEFAULT_RETRY, 63 use_new_schdoc_renderer: Optional[bool] = None, 64 ): 65 """Initializing an instance of the AllSpice Hub Client 66 67 Args: 68 allspice_hub_url (str): The URL for the AllSpice Hub instance. 69 Defaults to `https://hub.allspice.io`. 70 71 token_text (str, None): The access token, by default None. 72 73 auth (tuple, None): The user credentials 74 `(username, password)`, by default None. 75 76 verify (bool): If True, allow insecure server connections 77 when using SSL. 78 79 log_level (str): The log level, by default `INFO`. 80 81 ratelimiting (tuple[int, int], None): `(max_calls, period)`, 82 If None, no rate limiting is applied. By default, 100 calls 83 per minute are allowed. 84 85 retry (Retry, int, None): Set a retry policy for requests using a 86 urllib3 Retry object, an integer for the number of retries, or None 87 for no retries. This happens before py-allspice's own rate limiter. 88 The default is to retry all methods (even mutating methods) 89 only on 429s and 502s up to 6 times with exponential backoff 90 and jitter. 91 92 NOTE: AllSpice Hub responds with a 503 and a Retry-After header 93 when a generated file is not ready yet, and urllib3 retries any 94 503 that carries Retry-After. So with retries enabled, fetching 95 generated files transparently waits for generation instead of 96 raising NotYetGeneratedException, until retries are exhausted. 97 98 use_new_schdoc_renderer (bool): Allows explicit override for using the new Altium schematic renderer. If set, 99 this will take precedence over the default behavior on the AllSpice Hub instance. 100 """ 101 102 self.logger = logging.getLogger(__name__) 103 handler = logging.StreamHandler(sys.stderr) 104 handler.setFormatter( 105 logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 106 ) 107 self.logger.addHandler(handler) 108 self.logger.setLevel(log_level) 109 self.headers = { 110 "Content-type": "application/json", 111 } 112 self.url = allspice_hub_url 113 114 if ratelimiting is None: 115 self.requests = requests.Session() 116 else: 117 (max_calls, period) = ratelimiting 118 self.requests = RateLimitedSession(max_calls=max_calls, period=period) 119 120 if retry is not None: 121 adapter = HTTPAdapter(max_retries=retry) 122 self.requests.mount("https://", adapter) 123 self.requests.mount("http://", adapter) 124 125 # Manage authentification 126 if not token_text and not auth: 127 raise ValueError("Please provide auth or token_text, but not both") 128 if token_text: 129 self.headers["Authorization"] = "token " + token_text 130 if auth: 131 self.logger.warning( 132 "Using basic auth is not recommended. Prefer using a token instead." 133 ) 134 self.requests.auth = auth 135 136 # Manage SSL certification verification 137 self.requests.verify = verify 138 if not verify: 139 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 140 141 self.use_new_schdoc_renderer = use_new_schdoc_renderer 142 143 def __get_url(self, endpoint): 144 url = self.url + "/api/v1" + endpoint 145 self.logger.debug("Url: %s" % url) 146 return url 147 148 def __get(self, endpoint: str, params: Mapping = frozendict()) -> requests.Response: 149 response = self.requests.get(self.__get_url(endpoint), headers=self.headers, params=params) 150 if response.status_code not in [200, 201]: 151 message = f"Received status code: {response.status_code} ({response.url})" 152 if response.status_code in [404]: 153 raise NotFoundException(message) 154 if response.status_code in [403]: 155 raise Exception( 156 f"Unauthorized: {response.url} - Check your permissions and try again! ({message})" 157 ) 158 if response.status_code in [409]: 159 raise ConflictException(message) 160 if response.status_code in [503]: 161 raise NotYetGeneratedException(message) 162 if response.status_code in [500]: 163 raise InternalServerException(message, APIError.from_json(response.text)) 164 raise Exception(message) 165 return response 166 167 @staticmethod 168 def parse_result(result) -> Dict: 169 """Parses the result-JSON to a dict.""" 170 if result.text and len(result.text) > 3: 171 return json.loads(result.text) 172 return {} 173 174 def requests_get(self, endpoint: str, params: Mapping = frozendict(), sudo=None): 175 combined_params = {} 176 combined_params.update(params) 177 if sudo: 178 combined_params["sudo"] = sudo.username 179 return self.parse_result(self.__get(endpoint, combined_params)) 180 181 def requests_get_raw(self, endpoint: str, params=frozendict(), sudo=None) -> bytes: 182 combined_params = {} 183 combined_params.update(params) 184 if sudo: 185 combined_params["sudo"] = sudo.username 186 return self.__get(endpoint, combined_params).content 187 188 def requests_get_paginated( 189 self, 190 endpoint: str, 191 params=frozendict(), 192 sudo=None, 193 page_key: str = "page", 194 first_page: int = 1, 195 ): 196 page = first_page 197 combined_params = {} 198 combined_params.update(params) 199 aggregated_result = [] 200 while True: 201 combined_params[page_key] = page 202 result = self.requests_get(endpoint, combined_params, sudo) 203 204 if not result: 205 return aggregated_result 206 207 if isinstance(result, dict): 208 if "data" in result: 209 data = result["data"] 210 if len(data) == 0: 211 return aggregated_result 212 aggregated_result.extend(data) 213 elif "tree" in result: 214 data = result["tree"] 215 if data is None or len(data) == 0: 216 return aggregated_result 217 aggregated_result.extend(data) 218 else: 219 raise NotImplementedError( 220 "requests_get_paginated does not know how to handle responses of this type." 221 ) 222 else: 223 aggregated_result.extend(result) 224 225 page += 1 226 227 def requests_put(self, endpoint: str, data: Optional[dict] = None): 228 if not data: 229 data = {} 230 response = self.requests.put( 231 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 232 ) 233 if response.status_code not in [200, 204]: 234 message = ( 235 f"Received status code: {response.status_code} ({response.url}) {response.text}" 236 ) 237 self.logger.error(message) 238 raise Exception(message) 239 240 def requests_delete(self, endpoint: str, data: Optional[dict] = None): 241 response = self.requests.delete( 242 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 243 ) 244 if response.status_code not in [200, 204]: 245 message = f"Received status code: {response.status_code} ({response.url})" 246 self.logger.error(message) 247 raise Exception(message) 248 249 def requests_post( 250 self, 251 endpoint: str, 252 data: Optional[dict] = None, 253 params: Optional[dict] = None, 254 files: Optional[dict] = None, 255 ): 256 """ 257 Make a POST call to the endpoint. 258 259 :param endpoint: The path to the endpoint 260 :param data: A dictionary for JSON data 261 :param params: A dictionary of query params 262 :param files: A dictionary of files, see requests.post. Using both files and data 263 can lead to unexpected results! 264 :return: The JSON response parsed as a dict 265 """ 266 267 # This should ideally be a TypedDict of the type of arguments taken by 268 # `requests.post`. 269 args: dict[str, Any] = { 270 "headers": self.headers.copy(), 271 } 272 if data is not None: 273 args["data"] = json.dumps(data) 274 if params is not None: 275 args["params"] = params 276 if files is not None: 277 args["headers"].pop("Content-type") 278 args["files"] = files 279 280 response = self.requests.post(self.__get_url(endpoint), **args) 281 282 if response.status_code not in [200, 201, 202]: 283 if "already exists" in response.text or "e-mail already in use" in response.text: 284 self.logger.warning(response.text) 285 raise AlreadyExistsException() 286 self.logger.error(f"Received status code: {response.status_code} ({response.url})") 287 self.logger.error(f"With info: {data} ({self.headers})") 288 self.logger.error(f"Answer: {response.text}") 289 raise Exception( 290 f"Received status code: {response.status_code} ({response.url}), {response.text}" 291 ) 292 return self.parse_result(response) 293 294 def requests_patch(self, endpoint: str, data: dict): 295 response = self.requests.patch( 296 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 297 ) 298 if response.status_code not in [200, 201]: 299 error_message = f"Received status code: {response.status_code} ({response.url}) {data}" 300 self.logger.error(error_message) 301 raise Exception(error_message) 302 return self.parse_result(response) 303 304 def get_orgs_public_members_all(self, orgname): 305 path = "/orgs/" + orgname + "/public_members" 306 return self.requests_get(path) 307 308 def get_orgs(self): 309 path = "/admin/orgs" 310 results = self.requests_get(path) 311 return [Organization.parse_response(self, result) for result in results] 312 313 def get_user(self): 314 result = self.requests_get(AllSpice.GET_USER) 315 return User.parse_response(self, result) 316 317 def get_version(self) -> str: 318 result = self.requests_get(AllSpice.ALLSPICE_HUB_VERSION) 319 return result["version"] 320 321 def get_users(self) -> List[User]: 322 results = self.requests_get(AllSpice.GET_USERS_ADMIN) 323 return [User.parse_response(self, result) for result in results] 324 325 def get_user_by_email(self, email: str) -> Optional[User]: 326 users = self.get_users() 327 for user in users: 328 if user.email == email or email in user.emails: 329 return user 330 return None 331 332 def get_user_by_name(self, username: str) -> Optional[User]: 333 users = self.get_users() 334 for user in users: 335 if user.username == username: 336 return user 337 return None 338 339 def get_repository(self, owner: str, name: str) -> Repository: 340 path = self.GET_REPOSITORY.format(owner=owner, name=name) 341 result = self.requests_get(path) 342 return Repository.parse_response(self, result) 343 344 def create_user( 345 self, 346 user_name: str, 347 email: str, 348 password: str, 349 full_name: Optional[str] = None, 350 login_name: Optional[str] = None, 351 change_pw=True, 352 send_notify=True, 353 source_id=0, 354 ): 355 """Create User. 356 Throws: 357 AlreadyExistsException, if the User exists already 358 Exception, if something else went wrong. 359 """ 360 if not login_name: 361 login_name = user_name 362 if not full_name: 363 full_name = user_name 364 request_data = { 365 "source_id": source_id, 366 "login_name": login_name, 367 "full_name": full_name, 368 "username": user_name, 369 "email": email, 370 "password": password, 371 "send_notify": send_notify, 372 "must_change_password": change_pw, 373 } 374 375 self.logger.debug("Gitea post payload: %s", request_data) 376 result = self.requests_post(AllSpice.ADMIN_CREATE_USER, data=request_data) 377 if "id" in result: 378 self.logger.info( 379 "Successfully created User %s <%s> (id %s)", 380 result["login"], 381 result["email"], 382 result["id"], 383 ) 384 self.logger.debug("Gitea response: %s", result) 385 else: 386 self.logger.error(result["message"]) 387 raise Exception("User not created... (gitea: %s)" % result["message"]) 388 user = User.parse_response(self, result) 389 return user 390 391 def create_repo( 392 self, 393 repoOwner: Union[User, Organization], 394 repoName: str, 395 description: str = "", 396 private: bool = False, 397 autoInit=True, 398 gitignores: Optional[str] = None, 399 license: Optional[str] = None, 400 readme: str = "Default", 401 issue_labels: Optional[str] = None, 402 default_branch="master", 403 ): 404 """Create a Repository as the administrator 405 406 Throws: 407 AlreadyExistsException: If the Repository exists already. 408 Exception: If something else went wrong. 409 410 Note: 411 Non-admin users can not use this method. Please use instead 412 `allspice.User.create_repo` or `allspice.Organization.create_repo`. 413 """ 414 # although this only says user in the api, this also works for 415 # organizations 416 assert isinstance(repoOwner, User) or isinstance(repoOwner, Organization) 417 result = self.requests_post( 418 AllSpice.ADMIN_REPO_CREATE % repoOwner.username, 419 data={ 420 "name": repoName, 421 "description": description, 422 "private": private, 423 "auto_init": autoInit, 424 "gitignores": gitignores, 425 "license": license, 426 "issue_labels": issue_labels, 427 "readme": readme, 428 "default_branch": default_branch, 429 }, 430 ) 431 if "id" in result: 432 self.logger.info("Successfully created Repository %s " % result["name"]) 433 else: 434 self.logger.error(result["message"]) 435 raise Exception("Repository not created... (gitea: %s)" % result["message"]) 436 return Repository.parse_response(self, result) 437 438 def create_org( 439 self, 440 owner: User, 441 orgName: str, 442 description: str, 443 location="", 444 website="", 445 full_name="", 446 ): 447 assert isinstance(owner, User) 448 result = self.requests_post( 449 AllSpice.CREATE_ORG % owner.username, 450 data={ 451 "username": orgName, 452 "description": description, 453 "location": location, 454 "website": website, 455 "full_name": full_name, 456 }, 457 ) 458 if "id" in result: 459 self.logger.info("Successfully created Organization %s" % result["username"]) 460 else: 461 self.logger.error("Organization not created... (gitea: %s)" % result["message"]) 462 self.logger.error(result["message"]) 463 raise Exception("Organization not created... (gitea: %s)" % result["message"]) 464 return Organization.parse_response(self, result) 465 466 def create_team( 467 self, 468 org: Organization, 469 name: str, 470 description: str = "", 471 permission: str = "read", 472 can_create_org_repo: bool = False, 473 includes_all_repositories: bool = False, 474 units=( 475 "repo.code", 476 "repo.issues", 477 "repo.ext_issues", 478 "repo.wiki", 479 "repo.pulls", 480 "repo.releases", 481 "repo.ext_wiki", 482 ), 483 units_map={}, 484 ): 485 """Creates a Team. 486 487 Args: 488 org (Organization): Organization the Team will be part of. 489 name (str): The Name of the Team to be created. 490 description (str): Optional, None, short description of the new Team. 491 permission (str): Optional, 'read', What permissions the members 492 units_map (dict): Optional, {}, a mapping of units to their 493 permissions. If None or empty, the `permission` permission will 494 be applied to all units. Note: When both `units` and `units_map` 495 are given, `units_map` will be preferred. 496 """ 497 498 result = self.requests_post( 499 AllSpice.CREATE_TEAM % org.username, 500 data={ 501 "name": name, 502 "description": description, 503 "permission": permission, 504 "can_create_org_repo": can_create_org_repo, 505 "includes_all_repositories": includes_all_repositories, 506 "units": units, 507 "units_map": units_map, 508 }, 509 ) 510 511 if "id" in result: 512 self.logger.info("Successfully created Team %s" % result["name"]) 513 else: 514 self.logger.error("Team not created... (gitea: %s)" % result["message"]) 515 self.logger.error(result["message"]) 516 raise Exception("Team not created... (gitea: %s)" % result["message"]) 517 api_object = Team.parse_response(self, result) 518 setattr( 519 api_object, "_organization", org 520 ) # fixes strange behaviour of gitea not returning a valid organization here. 521 return api_object
43class AllSpice: 44 """Object to establish a session with AllSpice Hub.""" 45 46 ADMIN_CREATE_USER = """/admin/users""" 47 GET_USERS_ADMIN = """/admin/users""" 48 ADMIN_REPO_CREATE = """/admin/users/%s/repos""" # <ownername> 49 ALLSPICE_HUB_VERSION = """/version""" 50 GET_USER = """/user""" 51 GET_REPOSITORY = """/repos/{owner}/{name}""" 52 CREATE_ORG = """/admin/users/%s/orgs""" # <username> 53 CREATE_TEAM = """/orgs/%s/teams""" # <orgname> 54 55 def __init__( 56 self, 57 allspice_hub_url="https://hub.allspice.io", 58 token_text=None, 59 auth=None, 60 verify=True, 61 log_level="INFO", 62 ratelimiting=(100, 60), 63 retry: Union[Retry, int, None] = DEFAULT_RETRY, 64 use_new_schdoc_renderer: Optional[bool] = None, 65 ): 66 """Initializing an instance of the AllSpice Hub Client 67 68 Args: 69 allspice_hub_url (str): The URL for the AllSpice Hub instance. 70 Defaults to `https://hub.allspice.io`. 71 72 token_text (str, None): The access token, by default None. 73 74 auth (tuple, None): The user credentials 75 `(username, password)`, by default None. 76 77 verify (bool): If True, allow insecure server connections 78 when using SSL. 79 80 log_level (str): The log level, by default `INFO`. 81 82 ratelimiting (tuple[int, int], None): `(max_calls, period)`, 83 If None, no rate limiting is applied. By default, 100 calls 84 per minute are allowed. 85 86 retry (Retry, int, None): Set a retry policy for requests using a 87 urllib3 Retry object, an integer for the number of retries, or None 88 for no retries. This happens before py-allspice's own rate limiter. 89 The default is to retry all methods (even mutating methods) 90 only on 429s and 502s up to 6 times with exponential backoff 91 and jitter. 92 93 NOTE: AllSpice Hub responds with a 503 and a Retry-After header 94 when a generated file is not ready yet, and urllib3 retries any 95 503 that carries Retry-After. So with retries enabled, fetching 96 generated files transparently waits for generation instead of 97 raising NotYetGeneratedException, until retries are exhausted. 98 99 use_new_schdoc_renderer (bool): Allows explicit override for using the new Altium schematic renderer. If set, 100 this will take precedence over the default behavior on the AllSpice Hub instance. 101 """ 102 103 self.logger = logging.getLogger(__name__) 104 handler = logging.StreamHandler(sys.stderr) 105 handler.setFormatter( 106 logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 107 ) 108 self.logger.addHandler(handler) 109 self.logger.setLevel(log_level) 110 self.headers = { 111 "Content-type": "application/json", 112 } 113 self.url = allspice_hub_url 114 115 if ratelimiting is None: 116 self.requests = requests.Session() 117 else: 118 (max_calls, period) = ratelimiting 119 self.requests = RateLimitedSession(max_calls=max_calls, period=period) 120 121 if retry is not None: 122 adapter = HTTPAdapter(max_retries=retry) 123 self.requests.mount("https://", adapter) 124 self.requests.mount("http://", adapter) 125 126 # Manage authentification 127 if not token_text and not auth: 128 raise ValueError("Please provide auth or token_text, but not both") 129 if token_text: 130 self.headers["Authorization"] = "token " + token_text 131 if auth: 132 self.logger.warning( 133 "Using basic auth is not recommended. Prefer using a token instead." 134 ) 135 self.requests.auth = auth 136 137 # Manage SSL certification verification 138 self.requests.verify = verify 139 if not verify: 140 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 141 142 self.use_new_schdoc_renderer = use_new_schdoc_renderer 143 144 def __get_url(self, endpoint): 145 url = self.url + "/api/v1" + endpoint 146 self.logger.debug("Url: %s" % url) 147 return url 148 149 def __get(self, endpoint: str, params: Mapping = frozendict()) -> requests.Response: 150 response = self.requests.get(self.__get_url(endpoint), headers=self.headers, params=params) 151 if response.status_code not in [200, 201]: 152 message = f"Received status code: {response.status_code} ({response.url})" 153 if response.status_code in [404]: 154 raise NotFoundException(message) 155 if response.status_code in [403]: 156 raise Exception( 157 f"Unauthorized: {response.url} - Check your permissions and try again! ({message})" 158 ) 159 if response.status_code in [409]: 160 raise ConflictException(message) 161 if response.status_code in [503]: 162 raise NotYetGeneratedException(message) 163 if response.status_code in [500]: 164 raise InternalServerException(message, APIError.from_json(response.text)) 165 raise Exception(message) 166 return response 167 168 @staticmethod 169 def parse_result(result) -> Dict: 170 """Parses the result-JSON to a dict.""" 171 if result.text and len(result.text) > 3: 172 return json.loads(result.text) 173 return {} 174 175 def requests_get(self, endpoint: str, params: Mapping = frozendict(), sudo=None): 176 combined_params = {} 177 combined_params.update(params) 178 if sudo: 179 combined_params["sudo"] = sudo.username 180 return self.parse_result(self.__get(endpoint, combined_params)) 181 182 def requests_get_raw(self, endpoint: str, params=frozendict(), sudo=None) -> bytes: 183 combined_params = {} 184 combined_params.update(params) 185 if sudo: 186 combined_params["sudo"] = sudo.username 187 return self.__get(endpoint, combined_params).content 188 189 def requests_get_paginated( 190 self, 191 endpoint: str, 192 params=frozendict(), 193 sudo=None, 194 page_key: str = "page", 195 first_page: int = 1, 196 ): 197 page = first_page 198 combined_params = {} 199 combined_params.update(params) 200 aggregated_result = [] 201 while True: 202 combined_params[page_key] = page 203 result = self.requests_get(endpoint, combined_params, sudo) 204 205 if not result: 206 return aggregated_result 207 208 if isinstance(result, dict): 209 if "data" in result: 210 data = result["data"] 211 if len(data) == 0: 212 return aggregated_result 213 aggregated_result.extend(data) 214 elif "tree" in result: 215 data = result["tree"] 216 if data is None or len(data) == 0: 217 return aggregated_result 218 aggregated_result.extend(data) 219 else: 220 raise NotImplementedError( 221 "requests_get_paginated does not know how to handle responses of this type." 222 ) 223 else: 224 aggregated_result.extend(result) 225 226 page += 1 227 228 def requests_put(self, endpoint: str, data: Optional[dict] = None): 229 if not data: 230 data = {} 231 response = self.requests.put( 232 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 233 ) 234 if response.status_code not in [200, 204]: 235 message = ( 236 f"Received status code: {response.status_code} ({response.url}) {response.text}" 237 ) 238 self.logger.error(message) 239 raise Exception(message) 240 241 def requests_delete(self, endpoint: str, data: Optional[dict] = None): 242 response = self.requests.delete( 243 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 244 ) 245 if response.status_code not in [200, 204]: 246 message = f"Received status code: {response.status_code} ({response.url})" 247 self.logger.error(message) 248 raise Exception(message) 249 250 def requests_post( 251 self, 252 endpoint: str, 253 data: Optional[dict] = None, 254 params: Optional[dict] = None, 255 files: Optional[dict] = None, 256 ): 257 """ 258 Make a POST call to the endpoint. 259 260 :param endpoint: The path to the endpoint 261 :param data: A dictionary for JSON data 262 :param params: A dictionary of query params 263 :param files: A dictionary of files, see requests.post. Using both files and data 264 can lead to unexpected results! 265 :return: The JSON response parsed as a dict 266 """ 267 268 # This should ideally be a TypedDict of the type of arguments taken by 269 # `requests.post`. 270 args: dict[str, Any] = { 271 "headers": self.headers.copy(), 272 } 273 if data is not None: 274 args["data"] = json.dumps(data) 275 if params is not None: 276 args["params"] = params 277 if files is not None: 278 args["headers"].pop("Content-type") 279 args["files"] = files 280 281 response = self.requests.post(self.__get_url(endpoint), **args) 282 283 if response.status_code not in [200, 201, 202]: 284 if "already exists" in response.text or "e-mail already in use" in response.text: 285 self.logger.warning(response.text) 286 raise AlreadyExistsException() 287 self.logger.error(f"Received status code: {response.status_code} ({response.url})") 288 self.logger.error(f"With info: {data} ({self.headers})") 289 self.logger.error(f"Answer: {response.text}") 290 raise Exception( 291 f"Received status code: {response.status_code} ({response.url}), {response.text}" 292 ) 293 return self.parse_result(response) 294 295 def requests_patch(self, endpoint: str, data: dict): 296 response = self.requests.patch( 297 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 298 ) 299 if response.status_code not in [200, 201]: 300 error_message = f"Received status code: {response.status_code} ({response.url}) {data}" 301 self.logger.error(error_message) 302 raise Exception(error_message) 303 return self.parse_result(response) 304 305 def get_orgs_public_members_all(self, orgname): 306 path = "/orgs/" + orgname + "/public_members" 307 return self.requests_get(path) 308 309 def get_orgs(self): 310 path = "/admin/orgs" 311 results = self.requests_get(path) 312 return [Organization.parse_response(self, result) for result in results] 313 314 def get_user(self): 315 result = self.requests_get(AllSpice.GET_USER) 316 return User.parse_response(self, result) 317 318 def get_version(self) -> str: 319 result = self.requests_get(AllSpice.ALLSPICE_HUB_VERSION) 320 return result["version"] 321 322 def get_users(self) -> List[User]: 323 results = self.requests_get(AllSpice.GET_USERS_ADMIN) 324 return [User.parse_response(self, result) for result in results] 325 326 def get_user_by_email(self, email: str) -> Optional[User]: 327 users = self.get_users() 328 for user in users: 329 if user.email == email or email in user.emails: 330 return user 331 return None 332 333 def get_user_by_name(self, username: str) -> Optional[User]: 334 users = self.get_users() 335 for user in users: 336 if user.username == username: 337 return user 338 return None 339 340 def get_repository(self, owner: str, name: str) -> Repository: 341 path = self.GET_REPOSITORY.format(owner=owner, name=name) 342 result = self.requests_get(path) 343 return Repository.parse_response(self, result) 344 345 def create_user( 346 self, 347 user_name: str, 348 email: str, 349 password: str, 350 full_name: Optional[str] = None, 351 login_name: Optional[str] = None, 352 change_pw=True, 353 send_notify=True, 354 source_id=0, 355 ): 356 """Create User. 357 Throws: 358 AlreadyExistsException, if the User exists already 359 Exception, if something else went wrong. 360 """ 361 if not login_name: 362 login_name = user_name 363 if not full_name: 364 full_name = user_name 365 request_data = { 366 "source_id": source_id, 367 "login_name": login_name, 368 "full_name": full_name, 369 "username": user_name, 370 "email": email, 371 "password": password, 372 "send_notify": send_notify, 373 "must_change_password": change_pw, 374 } 375 376 self.logger.debug("Gitea post payload: %s", request_data) 377 result = self.requests_post(AllSpice.ADMIN_CREATE_USER, data=request_data) 378 if "id" in result: 379 self.logger.info( 380 "Successfully created User %s <%s> (id %s)", 381 result["login"], 382 result["email"], 383 result["id"], 384 ) 385 self.logger.debug("Gitea response: %s", result) 386 else: 387 self.logger.error(result["message"]) 388 raise Exception("User not created... (gitea: %s)" % result["message"]) 389 user = User.parse_response(self, result) 390 return user 391 392 def create_repo( 393 self, 394 repoOwner: Union[User, Organization], 395 repoName: str, 396 description: str = "", 397 private: bool = False, 398 autoInit=True, 399 gitignores: Optional[str] = None, 400 license: Optional[str] = None, 401 readme: str = "Default", 402 issue_labels: Optional[str] = None, 403 default_branch="master", 404 ): 405 """Create a Repository as the administrator 406 407 Throws: 408 AlreadyExistsException: If the Repository exists already. 409 Exception: If something else went wrong. 410 411 Note: 412 Non-admin users can not use this method. Please use instead 413 `allspice.User.create_repo` or `allspice.Organization.create_repo`. 414 """ 415 # although this only says user in the api, this also works for 416 # organizations 417 assert isinstance(repoOwner, User) or isinstance(repoOwner, Organization) 418 result = self.requests_post( 419 AllSpice.ADMIN_REPO_CREATE % repoOwner.username, 420 data={ 421 "name": repoName, 422 "description": description, 423 "private": private, 424 "auto_init": autoInit, 425 "gitignores": gitignores, 426 "license": license, 427 "issue_labels": issue_labels, 428 "readme": readme, 429 "default_branch": default_branch, 430 }, 431 ) 432 if "id" in result: 433 self.logger.info("Successfully created Repository %s " % result["name"]) 434 else: 435 self.logger.error(result["message"]) 436 raise Exception("Repository not created... (gitea: %s)" % result["message"]) 437 return Repository.parse_response(self, result) 438 439 def create_org( 440 self, 441 owner: User, 442 orgName: str, 443 description: str, 444 location="", 445 website="", 446 full_name="", 447 ): 448 assert isinstance(owner, User) 449 result = self.requests_post( 450 AllSpice.CREATE_ORG % owner.username, 451 data={ 452 "username": orgName, 453 "description": description, 454 "location": location, 455 "website": website, 456 "full_name": full_name, 457 }, 458 ) 459 if "id" in result: 460 self.logger.info("Successfully created Organization %s" % result["username"]) 461 else: 462 self.logger.error("Organization not created... (gitea: %s)" % result["message"]) 463 self.logger.error(result["message"]) 464 raise Exception("Organization not created... (gitea: %s)" % result["message"]) 465 return Organization.parse_response(self, result) 466 467 def create_team( 468 self, 469 org: Organization, 470 name: str, 471 description: str = "", 472 permission: str = "read", 473 can_create_org_repo: bool = False, 474 includes_all_repositories: bool = False, 475 units=( 476 "repo.code", 477 "repo.issues", 478 "repo.ext_issues", 479 "repo.wiki", 480 "repo.pulls", 481 "repo.releases", 482 "repo.ext_wiki", 483 ), 484 units_map={}, 485 ): 486 """Creates a Team. 487 488 Args: 489 org (Organization): Organization the Team will be part of. 490 name (str): The Name of the Team to be created. 491 description (str): Optional, None, short description of the new Team. 492 permission (str): Optional, 'read', What permissions the members 493 units_map (dict): Optional, {}, a mapping of units to their 494 permissions. If None or empty, the `permission` permission will 495 be applied to all units. Note: When both `units` and `units_map` 496 are given, `units_map` will be preferred. 497 """ 498 499 result = self.requests_post( 500 AllSpice.CREATE_TEAM % org.username, 501 data={ 502 "name": name, 503 "description": description, 504 "permission": permission, 505 "can_create_org_repo": can_create_org_repo, 506 "includes_all_repositories": includes_all_repositories, 507 "units": units, 508 "units_map": units_map, 509 }, 510 ) 511 512 if "id" in result: 513 self.logger.info("Successfully created Team %s" % result["name"]) 514 else: 515 self.logger.error("Team not created... (gitea: %s)" % result["message"]) 516 self.logger.error(result["message"]) 517 raise Exception("Team not created... (gitea: %s)" % result["message"]) 518 api_object = Team.parse_response(self, result) 519 setattr( 520 api_object, "_organization", org 521 ) # fixes strange behaviour of gitea not returning a valid organization here. 522 return api_object
Object to establish a session with AllSpice Hub.
55 def __init__( 56 self, 57 allspice_hub_url="https://hub.allspice.io", 58 token_text=None, 59 auth=None, 60 verify=True, 61 log_level="INFO", 62 ratelimiting=(100, 60), 63 retry: Union[Retry, int, None] = DEFAULT_RETRY, 64 use_new_schdoc_renderer: Optional[bool] = None, 65 ): 66 """Initializing an instance of the AllSpice Hub Client 67 68 Args: 69 allspice_hub_url (str): The URL for the AllSpice Hub instance. 70 Defaults to `https://hub.allspice.io`. 71 72 token_text (str, None): The access token, by default None. 73 74 auth (tuple, None): The user credentials 75 `(username, password)`, by default None. 76 77 verify (bool): If True, allow insecure server connections 78 when using SSL. 79 80 log_level (str): The log level, by default `INFO`. 81 82 ratelimiting (tuple[int, int], None): `(max_calls, period)`, 83 If None, no rate limiting is applied. By default, 100 calls 84 per minute are allowed. 85 86 retry (Retry, int, None): Set a retry policy for requests using a 87 urllib3 Retry object, an integer for the number of retries, or None 88 for no retries. This happens before py-allspice's own rate limiter. 89 The default is to retry all methods (even mutating methods) 90 only on 429s and 502s up to 6 times with exponential backoff 91 and jitter. 92 93 NOTE: AllSpice Hub responds with a 503 and a Retry-After header 94 when a generated file is not ready yet, and urllib3 retries any 95 503 that carries Retry-After. So with retries enabled, fetching 96 generated files transparently waits for generation instead of 97 raising NotYetGeneratedException, until retries are exhausted. 98 99 use_new_schdoc_renderer (bool): Allows explicit override for using the new Altium schematic renderer. If set, 100 this will take precedence over the default behavior on the AllSpice Hub instance. 101 """ 102 103 self.logger = logging.getLogger(__name__) 104 handler = logging.StreamHandler(sys.stderr) 105 handler.setFormatter( 106 logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 107 ) 108 self.logger.addHandler(handler) 109 self.logger.setLevel(log_level) 110 self.headers = { 111 "Content-type": "application/json", 112 } 113 self.url = allspice_hub_url 114 115 if ratelimiting is None: 116 self.requests = requests.Session() 117 else: 118 (max_calls, period) = ratelimiting 119 self.requests = RateLimitedSession(max_calls=max_calls, period=period) 120 121 if retry is not None: 122 adapter = HTTPAdapter(max_retries=retry) 123 self.requests.mount("https://", adapter) 124 self.requests.mount("http://", adapter) 125 126 # Manage authentification 127 if not token_text and not auth: 128 raise ValueError("Please provide auth or token_text, but not both") 129 if token_text: 130 self.headers["Authorization"] = "token " + token_text 131 if auth: 132 self.logger.warning( 133 "Using basic auth is not recommended. Prefer using a token instead." 134 ) 135 self.requests.auth = auth 136 137 # Manage SSL certification verification 138 self.requests.verify = verify 139 if not verify: 140 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 141 142 self.use_new_schdoc_renderer = use_new_schdoc_renderer
Initializing an instance of the AllSpice Hub Client
Args:
allspice_hub_url (str): The URL for the AllSpice Hub instance.
Defaults to https://hub.allspice.io.
token_text (str, None): The access token, by default None.
auth (tuple, None): The user credentials
`(username, password)`, by default None.
verify (bool): If True, allow insecure server connections
when using SSL.
log_level (str): The log level, by default `INFO`.
ratelimiting (tuple[int, int], None): `(max_calls, period)`,
If None, no rate limiting is applied. By default, 100 calls
per minute are allowed.
retry (Retry, int, None): Set a retry policy for requests using a
urllib3 Retry object, an integer for the number of retries, or None
for no retries. This happens before py-allspice's own rate limiter.
The default is to retry all methods (even mutating methods)
only on 429s and 502s up to 6 times with exponential backoff
and jitter.
NOTE: AllSpice Hub responds with a 503 and a Retry-After header
when a generated file is not ready yet, and urllib3 retries any
503 that carries Retry-After. So with retries enabled, fetching
generated files transparently waits for generation instead of
raising NotYetGeneratedException, until retries are exhausted.
use_new_schdoc_renderer (bool): Allows explicit override for using the new Altium schematic renderer. If set,
this will take precedence over the default behavior on the AllSpice Hub instance.
168 @staticmethod 169 def parse_result(result) -> Dict: 170 """Parses the result-JSON to a dict.""" 171 if result.text and len(result.text) > 3: 172 return json.loads(result.text) 173 return {}
Parses the result-JSON to a dict.
189 def requests_get_paginated( 190 self, 191 endpoint: str, 192 params=frozendict(), 193 sudo=None, 194 page_key: str = "page", 195 first_page: int = 1, 196 ): 197 page = first_page 198 combined_params = {} 199 combined_params.update(params) 200 aggregated_result = [] 201 while True: 202 combined_params[page_key] = page 203 result = self.requests_get(endpoint, combined_params, sudo) 204 205 if not result: 206 return aggregated_result 207 208 if isinstance(result, dict): 209 if "data" in result: 210 data = result["data"] 211 if len(data) == 0: 212 return aggregated_result 213 aggregated_result.extend(data) 214 elif "tree" in result: 215 data = result["tree"] 216 if data is None or len(data) == 0: 217 return aggregated_result 218 aggregated_result.extend(data) 219 else: 220 raise NotImplementedError( 221 "requests_get_paginated does not know how to handle responses of this type." 222 ) 223 else: 224 aggregated_result.extend(result) 225 226 page += 1
228 def requests_put(self, endpoint: str, data: Optional[dict] = None): 229 if not data: 230 data = {} 231 response = self.requests.put( 232 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 233 ) 234 if response.status_code not in [200, 204]: 235 message = ( 236 f"Received status code: {response.status_code} ({response.url}) {response.text}" 237 ) 238 self.logger.error(message) 239 raise Exception(message)
241 def requests_delete(self, endpoint: str, data: Optional[dict] = None): 242 response = self.requests.delete( 243 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 244 ) 245 if response.status_code not in [200, 204]: 246 message = f"Received status code: {response.status_code} ({response.url})" 247 self.logger.error(message) 248 raise Exception(message)
250 def requests_post( 251 self, 252 endpoint: str, 253 data: Optional[dict] = None, 254 params: Optional[dict] = None, 255 files: Optional[dict] = None, 256 ): 257 """ 258 Make a POST call to the endpoint. 259 260 :param endpoint: The path to the endpoint 261 :param data: A dictionary for JSON data 262 :param params: A dictionary of query params 263 :param files: A dictionary of files, see requests.post. Using both files and data 264 can lead to unexpected results! 265 :return: The JSON response parsed as a dict 266 """ 267 268 # This should ideally be a TypedDict of the type of arguments taken by 269 # `requests.post`. 270 args: dict[str, Any] = { 271 "headers": self.headers.copy(), 272 } 273 if data is not None: 274 args["data"] = json.dumps(data) 275 if params is not None: 276 args["params"] = params 277 if files is not None: 278 args["headers"].pop("Content-type") 279 args["files"] = files 280 281 response = self.requests.post(self.__get_url(endpoint), **args) 282 283 if response.status_code not in [200, 201, 202]: 284 if "already exists" in response.text or "e-mail already in use" in response.text: 285 self.logger.warning(response.text) 286 raise AlreadyExistsException() 287 self.logger.error(f"Received status code: {response.status_code} ({response.url})") 288 self.logger.error(f"With info: {data} ({self.headers})") 289 self.logger.error(f"Answer: {response.text}") 290 raise Exception( 291 f"Received status code: {response.status_code} ({response.url}), {response.text}" 292 ) 293 return self.parse_result(response)
Make a POST call to the endpoint.
Parameters
- endpoint: The path to the endpoint
- data: A dictionary for JSON data
- params: A dictionary of query params
- files: A dictionary of files, see requests.post. Using both files and data can lead to unexpected results!
Returns
The JSON response parsed as a dict
295 def requests_patch(self, endpoint: str, data: dict): 296 response = self.requests.patch( 297 self.__get_url(endpoint), headers=self.headers, data=json.dumps(data) 298 ) 299 if response.status_code not in [200, 201]: 300 error_message = f"Received status code: {response.status_code} ({response.url}) {data}" 301 self.logger.error(error_message) 302 raise Exception(error_message) 303 return self.parse_result(response)
345 def create_user( 346 self, 347 user_name: str, 348 email: str, 349 password: str, 350 full_name: Optional[str] = None, 351 login_name: Optional[str] = None, 352 change_pw=True, 353 send_notify=True, 354 source_id=0, 355 ): 356 """Create User. 357 Throws: 358 AlreadyExistsException, if the User exists already 359 Exception, if something else went wrong. 360 """ 361 if not login_name: 362 login_name = user_name 363 if not full_name: 364 full_name = user_name 365 request_data = { 366 "source_id": source_id, 367 "login_name": login_name, 368 "full_name": full_name, 369 "username": user_name, 370 "email": email, 371 "password": password, 372 "send_notify": send_notify, 373 "must_change_password": change_pw, 374 } 375 376 self.logger.debug("Gitea post payload: %s", request_data) 377 result = self.requests_post(AllSpice.ADMIN_CREATE_USER, data=request_data) 378 if "id" in result: 379 self.logger.info( 380 "Successfully created User %s <%s> (id %s)", 381 result["login"], 382 result["email"], 383 result["id"], 384 ) 385 self.logger.debug("Gitea response: %s", result) 386 else: 387 self.logger.error(result["message"]) 388 raise Exception("User not created... (gitea: %s)" % result["message"]) 389 user = User.parse_response(self, result) 390 return user
Create User. Throws: AlreadyExistsException, if the User exists already Exception, if something else went wrong.
392 def create_repo( 393 self, 394 repoOwner: Union[User, Organization], 395 repoName: str, 396 description: str = "", 397 private: bool = False, 398 autoInit=True, 399 gitignores: Optional[str] = None, 400 license: Optional[str] = None, 401 readme: str = "Default", 402 issue_labels: Optional[str] = None, 403 default_branch="master", 404 ): 405 """Create a Repository as the administrator 406 407 Throws: 408 AlreadyExistsException: If the Repository exists already. 409 Exception: If something else went wrong. 410 411 Note: 412 Non-admin users can not use this method. Please use instead 413 `allspice.User.create_repo` or `allspice.Organization.create_repo`. 414 """ 415 # although this only says user in the api, this also works for 416 # organizations 417 assert isinstance(repoOwner, User) or isinstance(repoOwner, Organization) 418 result = self.requests_post( 419 AllSpice.ADMIN_REPO_CREATE % repoOwner.username, 420 data={ 421 "name": repoName, 422 "description": description, 423 "private": private, 424 "auto_init": autoInit, 425 "gitignores": gitignores, 426 "license": license, 427 "issue_labels": issue_labels, 428 "readme": readme, 429 "default_branch": default_branch, 430 }, 431 ) 432 if "id" in result: 433 self.logger.info("Successfully created Repository %s " % result["name"]) 434 else: 435 self.logger.error(result["message"]) 436 raise Exception("Repository not created... (gitea: %s)" % result["message"]) 437 return Repository.parse_response(self, result)
Create a Repository as the administrator
Throws: AlreadyExistsException: If the Repository exists already. Exception: If something else went wrong.
Note:
Non-admin users can not use this method. Please use instead
allspice.User.create_repo or allspice.Organization.create_repo.
439 def create_org( 440 self, 441 owner: User, 442 orgName: str, 443 description: str, 444 location="", 445 website="", 446 full_name="", 447 ): 448 assert isinstance(owner, User) 449 result = self.requests_post( 450 AllSpice.CREATE_ORG % owner.username, 451 data={ 452 "username": orgName, 453 "description": description, 454 "location": location, 455 "website": website, 456 "full_name": full_name, 457 }, 458 ) 459 if "id" in result: 460 self.logger.info("Successfully created Organization %s" % result["username"]) 461 else: 462 self.logger.error("Organization not created... (gitea: %s)" % result["message"]) 463 self.logger.error(result["message"]) 464 raise Exception("Organization not created... (gitea: %s)" % result["message"]) 465 return Organization.parse_response(self, result)
467 def create_team( 468 self, 469 org: Organization, 470 name: str, 471 description: str = "", 472 permission: str = "read", 473 can_create_org_repo: bool = False, 474 includes_all_repositories: bool = False, 475 units=( 476 "repo.code", 477 "repo.issues", 478 "repo.ext_issues", 479 "repo.wiki", 480 "repo.pulls", 481 "repo.releases", 482 "repo.ext_wiki", 483 ), 484 units_map={}, 485 ): 486 """Creates a Team. 487 488 Args: 489 org (Organization): Organization the Team will be part of. 490 name (str): The Name of the Team to be created. 491 description (str): Optional, None, short description of the new Team. 492 permission (str): Optional, 'read', What permissions the members 493 units_map (dict): Optional, {}, a mapping of units to their 494 permissions. If None or empty, the `permission` permission will 495 be applied to all units. Note: When both `units` and `units_map` 496 are given, `units_map` will be preferred. 497 """ 498 499 result = self.requests_post( 500 AllSpice.CREATE_TEAM % org.username, 501 data={ 502 "name": name, 503 "description": description, 504 "permission": permission, 505 "can_create_org_repo": can_create_org_repo, 506 "includes_all_repositories": includes_all_repositories, 507 "units": units, 508 "units_map": units_map, 509 }, 510 ) 511 512 if "id" in result: 513 self.logger.info("Successfully created Team %s" % result["name"]) 514 else: 515 self.logger.error("Team not created... (gitea: %s)" % result["message"]) 516 self.logger.error(result["message"]) 517 raise Exception("Team not created... (gitea: %s)" % result["message"]) 518 api_object = Team.parse_response(self, result) 519 setattr( 520 api_object, "_organization", org 521 ) # fixes strange behaviour of gitea not returning a valid organization here. 522 return api_object
Creates a Team.
Args:
org (Organization): Organization the Team will be part of.
name (str): The Name of the Team to be created.
description (str): Optional, None, short description of the new Team.
permission (str): Optional, 'read', What permissions the members
units_map (dict): Optional, {}, a mapping of units to their
permissions. If None or empty, the permission permission will
be applied to all units. Note: When both units and units_map
are given, units_map will be preferred.