simpro-py-sdk 0.1.1__tar.gz → 0.2.0__tar.gz

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 (28) hide show
  1. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/PKG-INFO +1 -1
  2. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/pyproject.toml +1 -1
  3. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/__init__.py +5 -1
  4. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/client.py +18 -1
  5. simpro_py_sdk-0.2.0/src/simpro/ratelimit.py +86 -0
  6. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/resources/__init__.py +2 -1
  7. simpro_py_sdk-0.2.0/src/simpro/resources/leads.py +16 -0
  8. simpro_py_sdk-0.2.0/tests/test_rate_limiting.py +96 -0
  9. simpro_py_sdk-0.2.0/tests/test_ratelimit.py +72 -0
  10. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/tests/test_resources.py +62 -6
  11. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/uv.lock +1 -1
  12. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/.github/workflows/ci.yml +0 -0
  13. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/.github/workflows/publish.yml +0 -0
  14. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/.gitignore +0 -0
  15. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/.python-version +0 -0
  16. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/LICENCE.txt +0 -0
  17. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/README.md +0 -0
  18. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/_util.py +0 -0
  19. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/exceptions.py +0 -0
  20. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/py.typed +0 -0
  21. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/resources/_base.py +0 -0
  22. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/resources/companies.py +0 -0
  23. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/resources/customers.py +0 -0
  24. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/resources/jobs.py +0 -0
  25. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/src/simpro/resources/sites.py +0 -0
  26. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/tests/test_client.py +0 -0
  27. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/tests/test_errors.py +0 -0
  28. {simpro_py_sdk-0.1.1 → simpro_py_sdk-0.2.0}/tests/test_pagination.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simpro-py-sdk
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Python client for the SimPro Cloud REST API (v1.0).
5
5
  Project-URL: Homepage, https://github.com/xuanpablo/simpro-py
6
6
  Project-URL: Changelog, https://github.com/xuanpablo/simpro-py/releases
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simpro-py-sdk"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Python client for the SimPro Cloud REST API (v1.0)."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENCE.txt" }
@@ -17,15 +17,19 @@ from simpro.exceptions import (
17
17
  SimproRateLimitError,
18
18
  SimproValidationError,
19
19
  )
20
+ from simpro.ratelimit import DEFAULT_RATE_LIMIT, RateLimiter, SlidingWindowRateLimiter
20
21
 
21
- __version__ = "0.1.1"
22
+ __version__ = "0.2.0"
22
23
 
23
24
  __all__ = [
25
+ "DEFAULT_RATE_LIMIT",
26
+ "RateLimiter",
24
27
  "SimproAuthError",
25
28
  "SimproClient",
26
29
  "SimproError",
27
30
  "SimproNotFound",
28
31
  "SimproRateLimitError",
29
32
  "SimproValidationError",
33
+ "SlidingWindowRateLimiter",
30
34
  "__version__",
31
35
  ]
@@ -25,7 +25,8 @@ from simpro.exceptions import (
25
25
  SimproRateLimitError,
26
26
  SimproValidationError,
27
27
  )
28
- from simpro.resources import Companies, Customers, Jobs, Sites
28
+ from simpro.ratelimit import DEFAULT_RATE_LIMIT, RateLimiter, SlidingWindowRateLimiter
29
+ from simpro.resources import Companies, Customers, Jobs, Leads, Sites
29
30
 
30
31
  DEFAULT_TIMEOUT = 30.0
31
32
 
@@ -39,6 +40,14 @@ class SimproClient:
39
40
  api_key: API key bearer token (SimPro > Administration > API Keys).
40
41
  timeout: Per-request timeout in seconds.
41
42
  transport: Optional ``httpx`` transport override (testing/proxies).
43
+ rate_limit: Built-in pacing ceiling in requests/second. The
44
+ limiter starts no more than this many requests in any sliding
45
+ second. ``None`` disables pacing. Default 9.0 — just under
46
+ SimPro's documented limit of 10/second per build and IP,
47
+ which is shared across all integrations on the build.
48
+ rate_limiter: Explicit limiter, overriding ``rate_limit``. Pass
49
+ one shared instance to several clients pointed at the same
50
+ build so they count against a single budget.
42
51
  """
43
52
 
44
53
  def __init__(
@@ -48,9 +57,14 @@ class SimproClient:
48
57
  *,
49
58
  timeout: float = DEFAULT_TIMEOUT,
50
59
  transport: httpx.BaseTransport | None = None,
60
+ rate_limit: float | None = DEFAULT_RATE_LIMIT,
61
+ rate_limiter: RateLimiter | None = None,
51
62
  ) -> None:
52
63
  base = base_url.rstrip("/").removesuffix(API_PREFIX)
53
64
  self._base = base
65
+ if rate_limiter is None and rate_limit is not None:
66
+ rate_limiter = SlidingWindowRateLimiter(rate_limit)
67
+ self._limiter = rate_limiter
54
68
  self._http = httpx.Client(
55
69
  timeout=timeout,
56
70
  headers={
@@ -64,6 +78,7 @@ class SimproClient:
64
78
  self.customers = Customers(self)
65
79
  self.sites = Sites(self)
66
80
  self.jobs = Jobs(self)
81
+ self.leads = Leads(self)
67
82
 
68
83
  def __enter__(self) -> Self:
69
84
  return self
@@ -89,6 +104,8 @@ class SimproClient:
89
104
  e.g. ``/companies/0/customers/``. A 304 (If-Modified-Since hit) is
90
105
  returned to the caller as a normal response.
91
106
  """
107
+ if self._limiter is not None:
108
+ self._limiter.acquire()
92
109
  url = f"{self._base}{API_PREFIX}{path}"
93
110
  try:
94
111
  response = self._http.request(method, url, params=params, headers=headers)
@@ -0,0 +1,86 @@
1
+ """Client-side request pacing for SimPro's per-build rate limit.
2
+
3
+ SimPro enforces 10 API calls per second, per build and per IP (announced
4
+ August 2022, strictly enforced since 17 April 2025), and the budget is
5
+ shared across every integration hitting the build — not just this client.
6
+ ``SlidingWindowRateLimiter`` keeps this SDK inside the ceiling by starting
7
+ no more than ``rate_limit`` requests in any trailing second.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from collections.abc import Callable
16
+ from typing import Protocol
17
+
18
+ # SimPro's documented ceiling is 10 requests/second per build + IP, shared
19
+ # with all other integrations and UI traffic on the build. The default
20
+ # stays just under it to leave headroom for that other traffic.
21
+ DEFAULT_RATE_LIMIT = 9.0
22
+
23
+ Timer = Callable[[], float]
24
+ Sleeper = Callable[[float], None]
25
+
26
+
27
+ class RateLimiter(Protocol):
28
+ """Anything with an ``acquire()`` that blocks until a request may start.
29
+
30
+ Implement this protocol to plug in a custom limiter and pass it as
31
+ ``SimproClient(rate_limiter=...)``.
32
+ """
33
+
34
+ def acquire(self) -> None: ...
35
+
36
+
37
+ class SlidingWindowRateLimiter:
38
+ """Start at most ``rate_limit`` acquisitions per second, sliding window.
39
+
40
+ Keeps timestamps of recent acquisitions in a trailing window; when the
41
+ window is full, ``acquire`` sleeps until the oldest timestamp expires.
42
+ A burst first spends the whole budget immediately, then requests pace
43
+ at ``rate_limit`` per second.
44
+
45
+ A lock serialises ``acquire`` across threads sharing one limiter, so
46
+ multi-threaded pulls through one client (or several clients on the
47
+ same build) count against a single budget.
48
+
49
+ Args:
50
+ rate_limit: Allowed acquisitions per second. Must be positive.
51
+ Values below 1 stretch the window (0.5 -> one call per 2s).
52
+ timer: Clock callable (seconds, monotonic); injectable for tests.
53
+ sleeper: Sleep callable; injectable for tests.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ rate_limit: float,
59
+ *,
60
+ timer: Timer = time.monotonic,
61
+ sleeper: Sleeper = time.sleep,
62
+ ) -> None:
63
+ if rate_limit <= 0:
64
+ raise ValueError(f"rate_limit must be positive, got {rate_limit!r}")
65
+ self._max_events = max(1, int(rate_limit))
66
+ self._window = self._max_events / rate_limit
67
+ self._timer = timer
68
+ self._sleeper = sleeper
69
+ self._starts: deque[float] = deque()
70
+ self._lock = threading.Lock()
71
+
72
+ def acquire(self) -> None:
73
+ """Block until one acquisition fits inside the rate budget."""
74
+ with self._lock:
75
+ now = self._prune(self._timer())
76
+ while len(self._starts) >= self._max_events:
77
+ sleep_for = self._window - (now - self._starts[0])
78
+ if sleep_for > 0:
79
+ self._sleeper(sleep_for)
80
+ now = self._prune(self._timer())
81
+ self._starts.append(now)
82
+
83
+ def _prune(self, now: float) -> float:
84
+ while self._starts and now - self._starts[0] >= self._window:
85
+ self._starts.popleft()
86
+ return now
@@ -3,6 +3,7 @@
3
3
  from simpro.resources.companies import Companies
4
4
  from simpro.resources.customers import Customers
5
5
  from simpro.resources.jobs import Jobs
6
+ from simpro.resources.leads import Leads
6
7
  from simpro.resources.sites import Sites
7
8
 
8
- __all__ = ["Companies", "Customers", "Jobs", "Sites"]
9
+ __all__ = ["Companies", "Customers", "Jobs", "Leads", "Sites"]
@@ -0,0 +1,16 @@
1
+ """Leads endpoints: ``GET /api/v1.0/companies/{companyID}/leads/``.
2
+
3
+ List items are minimal (``ID`` + nested ``Customer``/``Site`` objects);
4
+ extra fields (``LeadName``, ``Stage``, dates, ``Forecast``…) come via the
5
+ ``columns`` csv query param — e.g. ``client.leads.list(0, columns="ID,LeadName,Stage")``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from simpro.resources._base import CompanyScopedResource
11
+
12
+
13
+ class Leads(CompanyScopedResource):
14
+ """Leads (pre-sale requests for quote)."""
15
+
16
+ _resource = "leads"
@@ -0,0 +1,96 @@
1
+ """Client-level rate limiting: built-in pacing, injection, opt-out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from simpro import (
11
+ DEFAULT_RATE_LIMIT,
12
+ RateLimiter,
13
+ SimproClient,
14
+ SlidingWindowRateLimiter,
15
+ )
16
+
17
+
18
+ class FakeClock:
19
+ """Deterministic timer/sleeper pair: sleeping advances the clock."""
20
+
21
+ def __init__(self) -> None:
22
+ self.now = 1000.0
23
+ self.sleeps: list[float] = []
24
+
25
+ def timer(self) -> float:
26
+ return self.now
27
+
28
+ def sleeper(self, seconds: float) -> None:
29
+ self.sleeps.append(seconds)
30
+ self.now += seconds
31
+
32
+
33
+ class CountingLimiter:
34
+ """Duck-typed RateLimiter that records acquire() calls."""
35
+
36
+ def __init__(self) -> None:
37
+ self.acquires = 0
38
+
39
+ def acquire(self) -> None:
40
+ self.acquires += 1
41
+
42
+
43
+ def ok_handler(request: httpx.Request) -> httpx.Response:
44
+ return httpx.Response(200, json=[])
45
+
46
+
47
+ def make_client(**kwargs: Any) -> SimproClient:
48
+ kwargs.setdefault("transport", httpx.MockTransport(ok_handler))
49
+ return SimproClient("https://t.simprosuite.com", "k", **kwargs)
50
+
51
+
52
+ def test_default_client_paces_at_default_rate_limit() -> None:
53
+ client = make_client()
54
+ assert isinstance(client._limiter, SlidingWindowRateLimiter)
55
+ assert DEFAULT_RATE_LIMIT == 9.0
56
+
57
+
58
+ def test_rate_limit_none_disables_pacing() -> None:
59
+ client = make_client(rate_limit=None)
60
+ assert client._limiter is None
61
+ for _ in range(50): # rapid burst: no exception, and no limiter to sleep
62
+ client.companies.list()
63
+
64
+
65
+ def test_invalid_rate_limit_rejected() -> None:
66
+ with pytest.raises(ValueError, match="positive"):
67
+ make_client(rate_limit=0)
68
+
69
+
70
+ def test_requests_are_paced_by_injected_limiter() -> None:
71
+ clock = FakeClock()
72
+ limiter = SlidingWindowRateLimiter(9, timer=clock.timer, sleeper=clock.sleeper)
73
+ client = make_client(rate_limiter=limiter)
74
+ for _ in range(10):
75
+ client.companies.list()
76
+ # 9 free at t=1000; the 10th waits one second for the window to clear.
77
+ assert clock.sleeps == [1.0]
78
+
79
+
80
+ def test_limiter_shared_across_clients_shares_one_budget() -> None:
81
+ clock = FakeClock()
82
+ limiter = SlidingWindowRateLimiter(9, timer=clock.timer, sleeper=clock.sleeper)
83
+ a = make_client(rate_limiter=limiter)
84
+ b = make_client(rate_limiter=limiter)
85
+ for _ in range(9):
86
+ a.companies.list()
87
+ b.companies.list() # 10th acquisition overall: must wait.
88
+ assert clock.sleeps == [1.0]
89
+
90
+
91
+ def test_custom_limiter_protocol_accepted() -> None:
92
+ limiter: RateLimiter = CountingLimiter()
93
+ client = make_client(rate_limiter=limiter)
94
+ client.companies.list()
95
+ client.customers.get(0, 7)
96
+ assert limiter.acquires == 2
@@ -0,0 +1,72 @@
1
+ """Sliding-window rate limiter unit behaviour (fake clock, no real sleeps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from simpro.ratelimit import DEFAULT_RATE_LIMIT, SlidingWindowRateLimiter
8
+
9
+
10
+ class FakeClock:
11
+ """Deterministic timer/sleeper pair: sleeping advances the clock."""
12
+
13
+ def __init__(self) -> None:
14
+ self.now = 1000.0
15
+ self.sleeps: list[float] = []
16
+
17
+ def timer(self) -> float:
18
+ return self.now
19
+
20
+ def sleeper(self, seconds: float) -> None:
21
+ self.sleeps.append(seconds)
22
+ self.now += seconds
23
+
24
+
25
+ def make_limiter(rate: float) -> tuple[SlidingWindowRateLimiter, FakeClock]:
26
+ clock = FakeClock()
27
+ return SlidingWindowRateLimiter(rate, timer=clock.timer, sleeper=clock.sleeper), clock
28
+
29
+
30
+ def test_default_rate_limit_just_under_simpro_ceiling() -> None:
31
+ # SimPro's documented ceiling is 10/s shared across ALL integrations on
32
+ # the build — the default leaves headroom for that other traffic.
33
+ assert DEFAULT_RATE_LIMIT == 9.0
34
+
35
+
36
+ def test_burst_up_to_budget_needs_no_sleep() -> None:
37
+ limiter, clock = make_limiter(9)
38
+ for _ in range(9):
39
+ limiter.acquire()
40
+ assert clock.sleeps == []
41
+
42
+
43
+ def test_request_beyond_budget_waits_for_oldest_to_expire() -> None:
44
+ limiter, clock = make_limiter(9)
45
+ for _ in range(9):
46
+ limiter.acquire()
47
+ limiter.acquire()
48
+ # Window is 1.0s wide and full of timestamps at t=1000: the 10th call
49
+ # must wait exactly one second for them to age out.
50
+ assert clock.sleeps == [1.0]
51
+
52
+
53
+ def test_budget_resets_each_window_for_long_pulls() -> None:
54
+ limiter, clock = make_limiter(3)
55
+ for _ in range(12):
56
+ limiter.acquire()
57
+ # 3 free at t=1000, then one 1s wait per exhausted window: calls 4, 7, 10.
58
+ assert clock.sleeps == [1.0, 1.0, 1.0]
59
+
60
+
61
+ def test_fractional_rate_stretches_the_window() -> None:
62
+ limiter, clock = make_limiter(0.5) # -> 1 event per 2.0s window
63
+ limiter.acquire()
64
+ limiter.acquire()
65
+ assert clock.sleeps == [2.0]
66
+
67
+
68
+ def test_non_positive_rate_limit_rejected() -> None:
69
+ with pytest.raises(ValueError, match="positive"):
70
+ SlidingWindowRateLimiter(0)
71
+ with pytest.raises(ValueError, match="positive"):
72
+ SlidingWindowRateLimiter(-5)
@@ -11,7 +11,9 @@ from simpro import SimproClient, SimproNotFound
11
11
 
12
12
 
13
13
  def make_client(handler: Any) -> SimproClient:
14
- return SimproClient("https://t.simprosuite.com", "k", transport=httpx.MockTransport(handler))
14
+ return SimproClient(
15
+ "https://t.simprosuite.com", "k", transport=httpx.MockTransport(handler)
16
+ )
15
17
 
16
18
 
17
19
  def full_url(request: httpx.Request) -> str:
@@ -72,7 +74,9 @@ def test_customer_get_falls_back_to_individuals_on_404() -> None:
72
74
  requests.append(request)
73
75
  if request.url.path.endswith("/customers/companies/7"):
74
76
  return httpx.Response(404, json={"message": "Not found"})
75
- return httpx.Response(200, json={"ID": 7, "GivenName": "Jane", "FamilyName": "Doe"})
77
+ return httpx.Response(
78
+ 200, json={"ID": 7, "GivenName": "Jane", "FamilyName": "Doe"}
79
+ )
76
80
 
77
81
  client = make_client(handler)
78
82
  result = client.customers.get(0, 7)
@@ -101,8 +105,14 @@ def test_sites_and_jobs_get_urls() -> None:
101
105
  client = make_client(handler)
102
106
  client.sites.get(0, 12)
103
107
  client.jobs.get(0, 34)
104
- assert str(requests[0].url) == "https://t.simprosuite.com/api/v1.0/companies/0/sites/12"
105
- assert full_url(requests[1]) == "https://t.simprosuite.com/api/v1.0/companies/0/jobs/34"
108
+ assert (
109
+ str(requests[0].url)
110
+ == "https://t.simprosuite.com/api/v1.0/companies/0/sites/12"
111
+ )
112
+ assert (
113
+ full_url(requests[1])
114
+ == "https://t.simprosuite.com/api/v1.0/companies/0/jobs/34"
115
+ )
106
116
 
107
117
 
108
118
  def test_sites_and_jobs_list_urls() -> None:
@@ -115,5 +125,51 @@ def test_sites_and_jobs_list_urls() -> None:
115
125
  client = make_client(handler)
116
126
  client.sites.list(0)
117
127
  client.jobs.list(0)
118
- assert full_url(requests[0]) == "https://t.simprosuite.com/api/v1.0/companies/0/sites/"
119
- assert full_url(requests[1]) == "https://t.simprosuite.com/api/v1.0/companies/0/jobs/"
128
+ assert (
129
+ full_url(requests[0]) == "https://t.simprosuite.com/api/v1.0/companies/0/sites/"
130
+ )
131
+ assert (
132
+ full_url(requests[1]) == "https://t.simprosuite.com/api/v1.0/companies/0/jobs/"
133
+ )
134
+
135
+
136
+ def test_leads_list_url_and_columns_filter() -> None:
137
+ requests: list[httpx.Request] = []
138
+
139
+ def handler(request: httpx.Request) -> httpx.Response:
140
+ requests.append(request)
141
+ return httpx.Response(200, json=[])
142
+
143
+ client = make_client(handler)
144
+ client.leads.list(0, columns="ID,LeadName,Stage")
145
+ assert (
146
+ full_url(requests[0]) == "https://t.simprosuite.com/api/v1.0/companies/0/leads/"
147
+ )
148
+ assert requests[0].url.params["columns"] == "ID,LeadName,Stage"
149
+
150
+
151
+ def test_leads_get_url() -> None:
152
+ requests: list[httpx.Request] = []
153
+
154
+ def handler(request: httpx.Request) -> httpx.Response:
155
+ requests.append(request)
156
+ return httpx.Response(200, json={"ID": 5, "Stage": "Open"})
157
+
158
+ client = make_client(handler)
159
+ assert client.leads.get(0, 5)["Stage"] == "Open"
160
+ assert (
161
+ full_url(requests[0])
162
+ == "https://t.simprosuite.com/api/v1.0/companies/0/leads/5"
163
+ )
164
+
165
+
166
+ def test_leads_iter_all_walks_pages() -> None:
167
+ def handler(request: httpx.Request) -> httpx.Response:
168
+ page = request.url.params.get("page", "1")
169
+ body = [{"ID": 1}, {"ID": 2}] if page == "1" else [{"ID": 3}]
170
+ return httpx.Response(200, json=body, headers={"Result-Pages": "2"})
171
+
172
+ client = SimproClient(
173
+ "https://t.simprosuite.com", "k", transport=httpx.MockTransport(handler)
174
+ )
175
+ assert [lead["ID"] for lead in client.leads.iter_all(0)] == [1, 2, 3]
@@ -399,7 +399,7 @@ wheels = [
399
399
 
400
400
  [[package]]
401
401
  name = "simpro-py-sdk"
402
- version = "0.1.1"
402
+ version = "0.2.0"
403
403
  source = { editable = "." }
404
404
  dependencies = [
405
405
  { name = "httpx" },
File without changes
File without changes
File without changes