spyreapi 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
spyre/client.py ADDED
@@ -0,0 +1,208 @@
1
+ import requests
2
+ from config import BASE_URL
3
+ from typing import TypeVar, Optional, Type, Generic, List, Union, Tuple, Dict, Any
4
+ from pydantic import BaseModel
5
+ import json
6
+ import urllib.parse
7
+
8
+ T = TypeVar('T', bound=BaseModel)
9
+
10
+ class SpireClient():
11
+
12
+ def __init__(self, company, username, password,):
13
+ self.session = requests.Session()
14
+ self.session.auth = (username, password)
15
+ self.session.headers.update({
16
+ "accept": "application/json",
17
+ "content-type": "application/json"
18
+ })
19
+ self.base_url = f"{BASE_URL}/{company}"
20
+
21
+ def _get(self, endpoint, params=None):
22
+
23
+ """
24
+ Send a GET request to the Spire API.
25
+
26
+ This method constructs the full URL using the provided endpoint,
27
+ sends an HTTP GET request with optional query parameters, and returns
28
+ the parsed JSON response. Raises an HTTPError if the response contains
29
+ an unsuccessful status code.
30
+
31
+ Args:
32
+ endpoint (str): The relative API endpoint (e.g., 'inventory/items/123').
33
+ params (dict, optional): A dictionary of query parameters to include
34
+ in the request (e.g., {'status': 'active'}). Defaults to None.
35
+
36
+ Returns:
37
+ dict: The JSON-decoded response from the API.
38
+
39
+ Raises:
40
+ requests.exceptions.HTTPError: If the response contains an HTTP error status.
41
+ """
42
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
43
+ response = self.session.get(url , params=params)
44
+ response.raise_for_status()
45
+ return response.json()
46
+
47
+ def _post(self, endpoint, data=None, json=None):
48
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
49
+ response = self.session.post(url, data=data, json=json)
50
+ return self._handle_response(response)
51
+
52
+ def _put(self, endpoint, data=None, json=None):
53
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
54
+ response = self.session.put(url, data=data, json=json)
55
+ response.raise_for_status()
56
+ return response.json()
57
+
58
+ def _delete(self, endpoint):
59
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
60
+ response = self.session.delete(url)
61
+ return response.status_code == 200 or response.status_code == 204 or response.status_code == 202
62
+
63
+ def _handle_response(self, response):
64
+
65
+ try:
66
+ content = response.json()
67
+ except ValueError:
68
+ content = response.text
69
+ return{
70
+ "status_code": response.status_code,
71
+ "url": response.url,
72
+ "content": content,
73
+ "headers" : response.headers
74
+ }
75
+
76
+ def _query(
77
+ self,
78
+ endpoint: str,
79
+ resource_cls: Type["APIResource[T]"],
80
+ *,
81
+ all: bool = False,
82
+ limit: int = 1000,
83
+ start: int = 0,
84
+ query: Optional[str] = None,
85
+ filter: Optional[Dict[str, Any]] = None,
86
+ sort: Optional[Dict[str, str]] = None,
87
+ **extra_params
88
+ ) -> List["APIResource[T]"]:
89
+ """
90
+ Query a list of resources from a Spire API endpoint with support for
91
+ pagination, searching, filtering, and multi-level sorting.
92
+
93
+ Args:
94
+ endpoint (str): The API endpoint (e.g., 'sales/orders').
95
+ resource_cls (Type[APIResource[T]]): The resource wrapper class (e.g., SalesOrderResource).
96
+ all (bool, optional): If True, fetches all available pages of results.
97
+ limit (int, optional): Number of results per page (max 1000). Default is 1000.
98
+ start (int, optional): Starting offset for pagination. Default is 0.
99
+ q (str, optional): Free-text search query.
100
+ filter (dict, optional): Dictionary of filter criteria, which will be JSON-encoded.
101
+ sort (dict, optional): Dictionary of sorting rules (e.g., {"orderDate": "desc", "orderNo": "asc"}).
102
+ **extra_params: Any additional query parameters to pass to the API.
103
+
104
+ Returns:
105
+ List[APIResource[T]]: A list of wrapped resource instances.
106
+ """
107
+ collected = []
108
+ current_start = start
109
+
110
+ while True:
111
+ # Build the query params as a list of tuples to allow repeated keys like 'sort'
112
+ params: List[Tuple[str, Any]] = [
113
+ ("start", current_start),
114
+ ("limit", min(limit, 1000))
115
+ ]
116
+
117
+ if query:
118
+ params.append(("q", query))
119
+
120
+ if filter:
121
+ encoded_filter = json.dumps(filter)
122
+ params.append(("filter", encoded_filter))
123
+
124
+ if sort:
125
+ for field, direction in sort.items():
126
+ prefix = "-" if direction.lower() == "desc" else ""
127
+ params.append(("sort", f"{prefix}{field}"))
128
+
129
+ # Add any additional custom parameters
130
+ for k, v in extra_params.items():
131
+ params.append((k, v))
132
+
133
+ response = self._get(endpoint.rstrip("/"), params=params)
134
+ items = response.get("records", [])
135
+ count = response.get("count", 0)
136
+
137
+ for item in items:
138
+ collected.append(resource_cls.from_json(item, self))
139
+
140
+ if not all or (current_start + limit >= count):
141
+ break
142
+
143
+ current_start += limit
144
+
145
+ return collected
146
+
147
+
148
+ class APIResource(Generic[T]):
149
+ """Active-record-like wrapper that knows a *session* and a *model*."""
150
+ _model: T
151
+ _client: SpireClient
152
+ Model: type
153
+ endpoint: str
154
+
155
+ def __init__(self, model: T, client: SpireClient, **kwargs):
156
+ object.__setattr__(self, "_model", model)
157
+ object.__setattr__(self, "_client", client)
158
+
159
+ # Let child classes handle extra kwargs
160
+ for k, v in kwargs.items():
161
+ setattr(self, k, v)
162
+
163
+ def __getattr__(self, item):
164
+ """ delegate unknown attributes to the Pydantic model.
165
+ if you try to access an attribute on your wrapper class that doesn't exist in that class.
166
+ It will automatically forward the request to self._model """
167
+
168
+ return getattr(self._model, item)
169
+
170
+ def __setattr__(self, key, value):
171
+ """
172
+ Delegate attribute setting to the Pydantic model unless the attribute
173
+ starts with an underscore (which indicates internal fields like _model or _client).
174
+ """
175
+ if key.startswith("_"):
176
+ object.__setattr__(self, key, value)
177
+ else:
178
+ setattr(self._model, key, value)
179
+
180
+ def __str__(self):
181
+ return self._model.model_dump_json(indent=2)
182
+
183
+ @property
184
+ def model(self) -> T:
185
+ """
186
+ Accessor for the underlying Pydantic model.
187
+
188
+ Returns:
189
+ T: The internal Pydantic model instance.
190
+ """
191
+ return self._model
192
+
193
+ @classmethod
194
+ def from_json(cls, json_data: dict, client: SpireClient, **kwargs) -> "APIResource":
195
+ model_instance = cls.Model(**json_data)
196
+ return cls(model_instance, client, **kwargs)
197
+
198
+ def refresh(self):
199
+ updated = self._client._get(f"{self.endpoint}/{self.id}")
200
+ self._model = self.Model(**updated)
201
+ return self
202
+
203
+ def to_dict(self):
204
+ return self._model.model_dump(exclude_unset=True, exclude_none=True)
205
+
206
+
207
+
208
+
spyre/config.py ADDED
@@ -0,0 +1,6 @@
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ BASE_URL = os.getenv("BASE_URL")
spyre/customers.py ADDED
@@ -0,0 +1,157 @@
1
+ from client import SpireClient
2
+ from Models.customers_models import Customer
3
+ from client import APIResource
4
+ from Exceptions import *
5
+ from urllib.parse import urlparse
6
+ from typing import Optional, Dict, Any, List
7
+
8
+ class CustomerClient():
9
+
10
+ def __init__(self, client : SpireClient):
11
+ self.client = client
12
+ self.endpoint = "customers"
13
+
14
+ def get_customer(self, id: int) -> "customer":
15
+ """
16
+ Retrieve a customer by ID.
17
+
18
+ Sends a GET request to the customer endpoint to fetch details of a specific customer.
19
+
20
+ Args:
21
+ id (int): The ID of the customer to retrieve.
22
+
23
+ Returns:
24
+ Customer: A Customer object populated with the retrieved data.
25
+ """
26
+
27
+ response = self.client._get(f"{self.endpoint}/{str(id)}")
28
+ return customer.from_json(json_data=response, client=self.client)
29
+
30
+ def create_customer(self, customer : 'Customer') -> "customer":
31
+ """
32
+ Create a new customer.
33
+
34
+ Sends a POST request to the customer endpoint with the customer data
35
+ to create a new customer record.
36
+
37
+ Args:
38
+ customer (Customer): The Customer object containing the data to be created.
39
+
40
+ Returns:
41
+ Customer: The newly created Customer object returned by the API.
42
+ """
43
+
44
+ response = self.client._post(f"/{self.endpoint}", json=customer.model_dump(exclude_unset=True, exclude_none=True))
45
+ if response.get('status_code') == 201:
46
+ location = response.get('headers').get('location')
47
+ parsed_url = urlparse(location)
48
+ path_segments = parsed_url.path.rstrip("/").split("/")
49
+ id = path_segments[-1]
50
+ return self.get_customer(id)
51
+ else:
52
+ error_message = response.get('content')
53
+ raise CreateRequestError(self.endpoint, status_code=response.get('status_code'), error_message=error_message)
54
+
55
+ def update_customer(self, id : int, customer : 'Customer') -> "customer":
56
+ """
57
+ Update an existing customer by ID.
58
+
59
+ Sends a PUT request to update a customer record using the provided customer data.
60
+
61
+ Args:
62
+ id (int): The ID of the customer to update.
63
+ customer (Customer): A Pydantic model representing the updated customer data.
64
+
65
+ Returns:
66
+ customer: A new Customer instance built from the updated response data.
67
+ """
68
+ response = self.client._put(f"/{self.endpoint}/{str(id)}", json=customer.model_dump(exclude_none=True, exclude_unset=True))
69
+ return customer.from_json(response, self.client)
70
+
71
+ def delete_customer(self, id : int) -> bool:
72
+ """
73
+ Delete a customer by ID.
74
+
75
+ Sends a DELETE request to the customer endpoint. Returns True if deletion was successful (HTTP 200/204),
76
+ otherwise returns False.
77
+
78
+ Args:
79
+ id (int): The ID of the customer to delete.
80
+
81
+ Returns:
82
+ bool: True if the customer was successfully deleted, False otherwise.
83
+ """
84
+ return self.client._delete(f"/{self.endpoint}/{str(id)}")
85
+
86
+
87
+ def query_invoices(
88
+ self,
89
+ *,
90
+ query: Optional[str] = None,
91
+ sort: Optional[Dict[str, str]] = None,
92
+ filter: Optional[Dict[str, Any]] = None,
93
+ all: bool = False,
94
+ limit: int = 1000,
95
+ start: int = 0,
96
+ **extra_params
97
+ ) -> List["customer"]:
98
+ """
99
+ Query customer with optional full-text search, filtering, multi-field sorting, and pagination.
100
+
101
+ Args:
102
+ q (str, optional): Full-text search string.
103
+ sort (dict, optional): Dictionary of sorting rules (e.g., {"orderDate": "desc", "orderNo": "asc"}).
104
+ filter (dict, optional): Dictionary of filters to apply (will be JSON-encoded and URL-safe).
105
+ all (bool, optional): If True, retrieves all pages of results.
106
+ limit (int, optional): Number of results per page (max 1000).
107
+ start (int, optional): Starting offset for pagination.
108
+ **extra_params: Any additional parameters to include in the query.
109
+
110
+ Returns:
111
+ List[customer]: List of wrapped customer resources.
112
+ """
113
+ return self.client._query(
114
+ endpoint=self.endpoint,
115
+ resource_cls=customer,
116
+ query=query,
117
+ sort=sort,
118
+ filter=filter,
119
+ all=all,
120
+ limit=limit,
121
+ start=start,
122
+ **extra_params
123
+ )
124
+
125
+
126
+ class customer(APIResource[Customer]):
127
+ endpoint = "customers"
128
+ Model = Customer
129
+
130
+
131
+ def delete(self):
132
+ """
133
+ Deletes the Customer from Spire.
134
+
135
+ Sends a DELETE request to the API to remove the customer with the current ID.
136
+
137
+ Returns:
138
+ bool: True if the order was successfully deleted (HTTP 204 or 200), False otherwise.
139
+ """
140
+ return self._client._delete(f"/{self.endpoint}/{str(self.id)}")
141
+
142
+ def update(self, customer: "customer" = None) -> 'customer':
143
+ """
144
+ Update the customer.
145
+
146
+ If no order object is provided, updates the current instance on the server.
147
+ If an order object is provided, updates the customer using the given data.
148
+
149
+ Args:
150
+ customer (customer, optional): An optional customer instance to use for the update.
151
+
152
+ Returns:
153
+ customer: The updated customer object reflecting the new status.
154
+ """
155
+ data = customer.model_dump(exclude_unset=True, exclude_none=True) if customer else self.model_dump(exclude_unset=True, exclude_none=True)
156
+ response = self._client._put(f"/{self.endpoint}/{str(self.id)}", json=data)
157
+ return customer.from_json(response, self._client)