simpro-py-sdk 0.1.0__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.
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/PKG-INFO +1 -1
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/pyproject.toml +1 -1
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/__init__.py +5 -1
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/client.py +18 -1
- simpro_py_sdk-0.2.0/src/simpro/py.typed +0 -0
- simpro_py_sdk-0.2.0/src/simpro/ratelimit.py +86 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/resources/__init__.py +2 -1
- simpro_py_sdk-0.2.0/src/simpro/resources/leads.py +16 -0
- simpro_py_sdk-0.2.0/tests/test_rate_limiting.py +96 -0
- simpro_py_sdk-0.2.0/tests/test_ratelimit.py +72 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/tests/test_resources.py +62 -6
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/uv.lock +1 -1
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/.github/workflows/ci.yml +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/.github/workflows/publish.yml +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/.gitignore +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/.python-version +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/LICENCE.txt +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/README.md +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/_util.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/exceptions.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/resources/_base.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/resources/companies.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/resources/customers.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/resources/jobs.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/src/simpro/resources/sites.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/tests/test_client.py +0 -0
- {simpro_py_sdk-0.1.0 → simpro_py_sdk-0.2.0}/tests/test_errors.py +0 -0
- {simpro_py_sdk-0.1.0 → 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.
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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)
|
|
File without changes
|
|
@@ -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(
|
|
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(
|
|
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
|
|
105
|
-
|
|
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
|
|
119
|
-
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|