amochka 0.4.1__tar.gz → 0.4.4__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.
- {amochka-0.4.1 → amochka-0.4.4}/PKG-INFO +1 -1
- {amochka-0.4.1 → amochka-0.4.4}/amochka/__init__.py +1 -1
- {amochka-0.4.1 → amochka-0.4.4}/amochka/client.py +31 -8
- {amochka-0.4.1 → amochka-0.4.4}/amochka/etl.py +2 -0
- {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/PKG-INFO +1 -1
- {amochka-0.4.1 → amochka-0.4.4}/pyproject.toml +1 -1
- {amochka-0.4.1 → amochka-0.4.4}/tests/test_etl.py +24 -1
- {amochka-0.4.1 → amochka-0.4.4}/tests/test_http.py +16 -1
- amochka-0.4.4/tests/test_notes_events.py +86 -0
- {amochka-0.4.1 → amochka-0.4.4}/tests/test_security.py +32 -0
- {amochka-0.4.1 → amochka-0.4.4}/tests/test_utils.py +2 -0
- amochka-0.4.1/tests/test_notes_events.py +0 -30
- {amochka-0.4.1 → amochka-0.4.4}/README.md +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/amochka/errors.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/SOURCES.txt +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/dependency_links.txt +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/requires.txt +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/top_level.txt +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/__init__.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/config.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/extractors.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/loaders.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/migrations/001_create_tables.sql +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/run_etl.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/etl/transformers.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/setup.cfg +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/tests/test_cache.py +0 -0
- {amochka-0.4.1 → amochka-0.4.4}/tests/test_client.py +0 -0
|
@@ -477,11 +477,15 @@ class AmoCRMClient:
|
|
|
477
477
|
'redirect_uri': os.environ.get('AMOCRM_REDIRECT_URI'),
|
|
478
478
|
}
|
|
479
479
|
# 2. Загружаем из файла или строки
|
|
480
|
-
elif os.path.exists(self.token_file):
|
|
480
|
+
elif self.token_file and os.path.exists(self.token_file):
|
|
481
481
|
with open(self.token_file, 'r') as f:
|
|
482
482
|
data = json.load(f)
|
|
483
483
|
self.logger.debug(f"Token loaded from file: {self.token_file}")
|
|
484
484
|
else:
|
|
485
|
+
if not self.token_file:
|
|
486
|
+
raise AuthenticationError(
|
|
487
|
+
"Токен не найден: ни в environment variables, ни в файле, ни в переданной строке."
|
|
488
|
+
)
|
|
485
489
|
try:
|
|
486
490
|
data = json.loads(self.token_file)
|
|
487
491
|
self.logger.debug("Token parsed from provided string.")
|
|
@@ -583,7 +587,11 @@ class AmoCRMClient:
|
|
|
583
587
|
if response.status_code in (200, 204):
|
|
584
588
|
if response.status_code == 204:
|
|
585
589
|
return None
|
|
586
|
-
|
|
590
|
+
try:
|
|
591
|
+
return response.json()
|
|
592
|
+
except ValueError:
|
|
593
|
+
preview = response.text[:200] if response.text else ""
|
|
594
|
+
raise APIError(response.status_code, f"Invalid JSON response: {preview}")
|
|
587
595
|
|
|
588
596
|
# Retryable ошибки (429, 5xx)
|
|
589
597
|
if response.status_code in retryable_status_codes:
|
|
@@ -675,7 +683,10 @@ class AmoCRMClient:
|
|
|
675
683
|
self.logger.error(f"Не удалось обновить токен: {resp.status_code}")
|
|
676
684
|
raise AuthenticationError(f"Не удалось обновить токен: {resp.status_code}")
|
|
677
685
|
|
|
678
|
-
|
|
686
|
+
try:
|
|
687
|
+
data = resp.json() or {}
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
raise AuthenticationError("Ответ refresh_token не является валидным JSON") from exc
|
|
679
690
|
access_token = data.get("access_token")
|
|
680
691
|
refresh_token = data.get("refresh_token", self.refresh_token)
|
|
681
692
|
expires_in = data.get("expires_in")
|
|
@@ -921,7 +932,10 @@ class AmoCRMClient:
|
|
|
921
932
|
return int(value)
|
|
922
933
|
if isinstance(value, str):
|
|
923
934
|
try:
|
|
924
|
-
|
|
935
|
+
value_str = value.strip()
|
|
936
|
+
if value_str.endswith("Z"):
|
|
937
|
+
value_str = value_str[:-1] + "+00:00"
|
|
938
|
+
return int(datetime.fromisoformat(value_str).timestamp())
|
|
925
939
|
except ValueError as exc:
|
|
926
940
|
raise ValueError(f"Не удалось преобразовать '{value}' в timestamp") from exc
|
|
927
941
|
raise TypeError(f"Неподдерживаемый тип для timestamp: {type(value)}")
|
|
@@ -1214,6 +1228,8 @@ class AmoCRMClient:
|
|
|
1214
1228
|
if end_ts is not None:
|
|
1215
1229
|
params["filter[updated_at][to]"] = end_ts
|
|
1216
1230
|
note_type_param = self._format_filter_values(note_type)
|
|
1231
|
+
if isinstance(note_type_param, (list, tuple, set)):
|
|
1232
|
+
note_type_param = ",".join(note_type_param)
|
|
1217
1233
|
if note_type_param:
|
|
1218
1234
|
params["filter[note_type]"] = note_type_param
|
|
1219
1235
|
entity_param = self._format_filter_values(entity_ids)
|
|
@@ -1583,8 +1599,11 @@ class AmoCRMClient:
|
|
|
1583
1599
|
"page": 1,
|
|
1584
1600
|
"limit": 250
|
|
1585
1601
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1602
|
+
note_type_param = self._format_filter_values(note_type)
|
|
1603
|
+
if isinstance(note_type_param, (list, tuple, set)):
|
|
1604
|
+
note_type_param = ",".join(note_type_param)
|
|
1605
|
+
if note_type_param:
|
|
1606
|
+
params["filter[note_type]"] = note_type_param
|
|
1588
1607
|
if extra_params:
|
|
1589
1608
|
params.update(extra_params)
|
|
1590
1609
|
|
|
@@ -1592,7 +1611,7 @@ class AmoCRMClient:
|
|
|
1592
1611
|
while True:
|
|
1593
1612
|
response = self._make_request("GET", endpoint, params=params)
|
|
1594
1613
|
if not response:
|
|
1595
|
-
self.logger.
|
|
1614
|
+
self.logger.debug(f"Empty response for notes {entity} {entity_id}, stopping pagination")
|
|
1596
1615
|
break
|
|
1597
1616
|
if response and "_embedded" in response and "notes" in response["_embedded"]:
|
|
1598
1617
|
notes.extend(response["_embedded"]["notes"])
|
|
@@ -1628,6 +1647,8 @@ class AmoCRMClient:
|
|
|
1628
1647
|
endpoint = f"/api/v4/{plural}/{entity_id}/notes/{note_id}"
|
|
1629
1648
|
self.logger.debug(f"Fetching note {note_id} for {entity} {entity_id}")
|
|
1630
1649
|
note_data = self._make_request("GET", endpoint)
|
|
1650
|
+
if not note_data or not isinstance(note_data, dict):
|
|
1651
|
+
raise APIError(0, f"Invalid response for note {note_id} {entity} {entity_id}.")
|
|
1631
1652
|
self.logger.debug(f"Note {note_id} for {entity} {entity_id} fetched successfully.")
|
|
1632
1653
|
return note_data
|
|
1633
1654
|
|
|
@@ -1674,7 +1695,7 @@ class AmoCRMClient:
|
|
|
1674
1695
|
while True:
|
|
1675
1696
|
response = self._make_request("GET", "/api/v4/events", params=params)
|
|
1676
1697
|
if not response:
|
|
1677
|
-
self.logger.
|
|
1698
|
+
self.logger.debug(f"Empty response for events {entity} {entity_id}, stopping pagination")
|
|
1678
1699
|
break
|
|
1679
1700
|
if response and "_embedded" in response and "events" in response["_embedded"]:
|
|
1680
1701
|
events.extend(response["_embedded"]["events"])
|
|
@@ -1742,6 +1763,8 @@ class AmoCRMClient:
|
|
|
1742
1763
|
endpoint = f"/api/v4/events/{event_id}"
|
|
1743
1764
|
self.logger.debug(f"Fetching event with ID {event_id}")
|
|
1744
1765
|
event_data = self._make_request("GET", endpoint)
|
|
1766
|
+
if not event_data or not isinstance(event_data, dict):
|
|
1767
|
+
raise APIError(0, f"Invalid response for event {event_id}.")
|
|
1745
1768
|
self.logger.debug(f"Event {event_id} details fetched successfully.")
|
|
1746
1769
|
return event_data
|
|
1747
1770
|
|
|
@@ -123,6 +123,8 @@ def export_contacts_to_ndjson(
|
|
|
123
123
|
params["limit"] = limit
|
|
124
124
|
while True:
|
|
125
125
|
response = client._make_request("GET", "/api/v4/contacts", params=params)
|
|
126
|
+
if not response:
|
|
127
|
+
break
|
|
126
128
|
embedded = (response or {}).get("_embedded", {})
|
|
127
129
|
contacts = embedded.get("contacts") or []
|
|
128
130
|
if not contacts:
|
|
@@ -4,7 +4,7 @@ import sys
|
|
|
4
4
|
|
|
5
5
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
6
6
|
|
|
7
|
-
from amochka.etl import write_ndjson
|
|
7
|
+
from amochka.etl import export_contacts_to_ndjson, write_ndjson
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def test_write_ndjson_transform_and_on_record(tmp_path):
|
|
@@ -32,3 +32,26 @@ def test_write_ndjson_transform_and_on_record(tmp_path):
|
|
|
32
32
|
assert payload["account_id"] == 42
|
|
33
33
|
assert payload["payload"]["flag"] is True
|
|
34
34
|
assert seen == [1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_export_contacts_handles_empty_response(tmp_path):
|
|
38
|
+
class DummyClient:
|
|
39
|
+
def _make_request(self, *args, **kwargs):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def get_contact_by_id(self, *_args, **_kwargs):
|
|
43
|
+
raise Exception("not found")
|
|
44
|
+
|
|
45
|
+
def iter_contacts(self, *args, **kwargs):
|
|
46
|
+
return iter(())
|
|
47
|
+
|
|
48
|
+
client = DummyClient()
|
|
49
|
+
output_path = tmp_path / "contacts.ndjson"
|
|
50
|
+
count = export_contacts_to_ndjson(
|
|
51
|
+
client,
|
|
52
|
+
output_path,
|
|
53
|
+
account_id=1,
|
|
54
|
+
contact_ids=[123],
|
|
55
|
+
)
|
|
56
|
+
assert count == 0
|
|
57
|
+
assert output_path.read_text() == ""
|
|
@@ -12,13 +12,16 @@ from amochka import AmoCRMClient, CacheConfig, APIError, NotFoundError, RateLimi
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class FakeResponse:
|
|
15
|
-
def __init__(self, status_code, json_data=None, text="", headers=None):
|
|
15
|
+
def __init__(self, status_code, json_data=None, text="", headers=None, raise_on_json=False):
|
|
16
16
|
self.status_code = status_code
|
|
17
17
|
self._json_data = json_data
|
|
18
18
|
self.text = text
|
|
19
19
|
self.headers = headers or {}
|
|
20
|
+
self._raise_on_json = raise_on_json
|
|
20
21
|
|
|
21
22
|
def json(self):
|
|
23
|
+
if self._raise_on_json:
|
|
24
|
+
raise ValueError("invalid json")
|
|
22
25
|
return self._json_data
|
|
23
26
|
|
|
24
27
|
|
|
@@ -43,6 +46,18 @@ def test_make_request_success_json(monkeypatch):
|
|
|
43
46
|
assert client._make_request("GET", "/api/v4/leads") == {"ok": True}
|
|
44
47
|
|
|
45
48
|
|
|
49
|
+
def test_make_request_invalid_json_raises(monkeypatch):
|
|
50
|
+
client = make_client()
|
|
51
|
+
|
|
52
|
+
def fake_request(*args, **kwargs):
|
|
53
|
+
return FakeResponse(200, text="not json", raise_on_json=True)
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(client_module.requests, "request", fake_request)
|
|
56
|
+
with pytest.raises(APIError) as excinfo:
|
|
57
|
+
client._make_request("GET", "/api/v4/leads")
|
|
58
|
+
assert "Invalid JSON response" in str(excinfo.value)
|
|
59
|
+
|
|
60
|
+
|
|
46
61
|
def test_make_request_204_returns_none(monkeypatch):
|
|
47
62
|
client = make_client()
|
|
48
63
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from amochka import AmoCRMClient, CacheConfig, APIError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_client():
|
|
14
|
+
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
15
|
+
return AmoCRMClient(
|
|
16
|
+
base_url="https://example.amocrm.ru",
|
|
17
|
+
token_file=json.dumps(token_data),
|
|
18
|
+
cache_config=CacheConfig.disabled(),
|
|
19
|
+
disable_logging=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_get_entity_notes_handles_none_response(monkeypatch):
|
|
24
|
+
client = make_client()
|
|
25
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
26
|
+
assert client.get_entity_notes("lead", 1, get_all=True) == []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_get_entity_events_handles_none_response(monkeypatch):
|
|
30
|
+
client = make_client()
|
|
31
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
32
|
+
assert client.get_entity_events("lead", 1, get_all=True) == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_get_entity_notes_note_type_list_serialized(monkeypatch):
|
|
36
|
+
client = make_client()
|
|
37
|
+
captured = {}
|
|
38
|
+
|
|
39
|
+
def fake_request(method, endpoint, params=None, **kwargs):
|
|
40
|
+
captured["params"] = params
|
|
41
|
+
return {"_embedded": {"notes": []}, "_page_count": 1}
|
|
42
|
+
|
|
43
|
+
monkeypatch.setattr(client, "_make_request", fake_request)
|
|
44
|
+
notes = client.get_entity_notes("lead", 1, note_type=["common", "call_in"])
|
|
45
|
+
assert notes == []
|
|
46
|
+
assert captured["params"]["filter[note_type]"] == "common,call_in"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_get_entity_notes_note_type_string_kept(monkeypatch):
|
|
50
|
+
client = make_client()
|
|
51
|
+
captured = {}
|
|
52
|
+
|
|
53
|
+
def fake_request(method, endpoint, params=None, **kwargs):
|
|
54
|
+
captured["params"] = params
|
|
55
|
+
return {"_embedded": {"notes": []}, "_page_count": 1}
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(client, "_make_request", fake_request)
|
|
58
|
+
client.get_entity_notes("lead", 1, note_type="common")
|
|
59
|
+
assert captured["params"]["filter[note_type]"] == "common"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_iter_notes_note_type_list_serialized(monkeypatch):
|
|
63
|
+
client = make_client()
|
|
64
|
+
captured = {}
|
|
65
|
+
|
|
66
|
+
def fake_request(method, endpoint, params=None, **kwargs):
|
|
67
|
+
captured["params"] = params
|
|
68
|
+
return {"_embedded": {"notes": []}, "_page_count": 1}
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(client, "_make_request", fake_request)
|
|
71
|
+
list(client.iter_notes(note_type=["common", "call_in"], max_pages=1))
|
|
72
|
+
assert captured["params"]["filter[note_type]"] == "common,call_in"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_get_entity_note_invalid_response_raises(monkeypatch):
|
|
76
|
+
client = make_client()
|
|
77
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
78
|
+
with pytest.raises(APIError):
|
|
79
|
+
client.get_entity_note("lead", 1, 2)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_get_event_invalid_response_raises(monkeypatch):
|
|
83
|
+
client = make_client()
|
|
84
|
+
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
85
|
+
with pytest.raises(APIError):
|
|
86
|
+
client.get_event(123)
|
|
@@ -93,6 +93,16 @@ def test_invalid_token_string_raises():
|
|
|
93
93
|
)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def test_missing_token_file_and_env_raises():
|
|
97
|
+
with pytest.raises(AuthenticationError):
|
|
98
|
+
AmoCRMClient(
|
|
99
|
+
base_url="https://example.amocrm.ru",
|
|
100
|
+
token_file=None,
|
|
101
|
+
cache_config=CacheConfig.disabled(),
|
|
102
|
+
disable_logging=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
96
106
|
def test_refresh_access_token_success(monkeypatch, tmp_path):
|
|
97
107
|
token_path = tmp_path / "token.json"
|
|
98
108
|
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
@@ -139,6 +149,28 @@ def test_refresh_access_token_failure(monkeypatch, tmp_path):
|
|
|
139
149
|
)
|
|
140
150
|
|
|
141
151
|
|
|
152
|
+
def test_refresh_access_token_invalid_json(monkeypatch, tmp_path):
|
|
153
|
+
token_path = tmp_path / "token.json"
|
|
154
|
+
token_path.write_text(json.dumps(_token_payload(time.time() - 10)))
|
|
155
|
+
|
|
156
|
+
class BadJsonResponse:
|
|
157
|
+
status_code = 200
|
|
158
|
+
text = "not json"
|
|
159
|
+
|
|
160
|
+
def json(self):
|
|
161
|
+
raise ValueError("invalid json")
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(client_module.requests, "post", lambda *args, **kwargs: BadJsonResponse())
|
|
164
|
+
|
|
165
|
+
with pytest.raises(AuthenticationError):
|
|
166
|
+
AmoCRMClient(
|
|
167
|
+
base_url="https://example.amocrm.ru",
|
|
168
|
+
token_file=str(token_path),
|
|
169
|
+
cache_config=CacheConfig.disabled(),
|
|
170
|
+
disable_logging=True,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
142
174
|
def test_cache_path_validation():
|
|
143
175
|
with pytest.raises(ValueError):
|
|
144
176
|
CacheConfig._validate_path("../secret", "base_dir")
|
|
@@ -46,6 +46,8 @@ def test_to_timestamp():
|
|
|
46
46
|
assert client._to_timestamp(1700000000.5) == 1700000000
|
|
47
47
|
iso = "2020-01-01T00:00:00"
|
|
48
48
|
assert client._to_timestamp(iso) == int(datetime.fromisoformat(iso).timestamp())
|
|
49
|
+
iso_z = "2020-01-01T00:00:00Z"
|
|
50
|
+
assert client._to_timestamp(iso_z) == int(datetime.fromisoformat("2020-01-01T00:00:00+00:00").timestamp())
|
|
49
51
|
with pytest.raises(ValueError):
|
|
50
52
|
client._to_timestamp("bad-date")
|
|
51
53
|
with pytest.raises(TypeError):
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os
|
|
3
|
-
import sys
|
|
4
|
-
import time
|
|
5
|
-
|
|
6
|
-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
7
|
-
|
|
8
|
-
from amochka import AmoCRMClient, CacheConfig
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def make_client():
|
|
12
|
-
token_data = {"access_token": "token", "expires_at": str(time.time() + 3600)}
|
|
13
|
-
return AmoCRMClient(
|
|
14
|
-
base_url="https://example.amocrm.ru",
|
|
15
|
-
token_file=json.dumps(token_data),
|
|
16
|
-
cache_config=CacheConfig.disabled(),
|
|
17
|
-
disable_logging=True,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_get_entity_notes_handles_none_response(monkeypatch):
|
|
22
|
-
client = make_client()
|
|
23
|
-
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
24
|
-
assert client.get_entity_notes("lead", 1, get_all=True) == []
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_get_entity_events_handles_none_response(monkeypatch):
|
|
28
|
-
client = make_client()
|
|
29
|
-
monkeypatch.setattr(client, "_make_request", lambda *args, **kwargs: None)
|
|
30
|
-
assert client.get_entity_events("lead", 1, get_all=True) == []
|
|
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
|