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.
Files changed (28) hide show
  1. {amochka-0.4.1 → amochka-0.4.4}/PKG-INFO +1 -1
  2. {amochka-0.4.1 → amochka-0.4.4}/amochka/__init__.py +1 -1
  3. {amochka-0.4.1 → amochka-0.4.4}/amochka/client.py +31 -8
  4. {amochka-0.4.1 → amochka-0.4.4}/amochka/etl.py +2 -0
  5. {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/PKG-INFO +1 -1
  6. {amochka-0.4.1 → amochka-0.4.4}/pyproject.toml +1 -1
  7. {amochka-0.4.1 → amochka-0.4.4}/tests/test_etl.py +24 -1
  8. {amochka-0.4.1 → amochka-0.4.4}/tests/test_http.py +16 -1
  9. amochka-0.4.4/tests/test_notes_events.py +86 -0
  10. {amochka-0.4.1 → amochka-0.4.4}/tests/test_security.py +32 -0
  11. {amochka-0.4.1 → amochka-0.4.4}/tests/test_utils.py +2 -0
  12. amochka-0.4.1/tests/test_notes_events.py +0 -30
  13. {amochka-0.4.1 → amochka-0.4.4}/README.md +0 -0
  14. {amochka-0.4.1 → amochka-0.4.4}/amochka/errors.py +0 -0
  15. {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/SOURCES.txt +0 -0
  16. {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/dependency_links.txt +0 -0
  17. {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/requires.txt +0 -0
  18. {amochka-0.4.1 → amochka-0.4.4}/amochka.egg-info/top_level.txt +0 -0
  19. {amochka-0.4.1 → amochka-0.4.4}/etl/__init__.py +0 -0
  20. {amochka-0.4.1 → amochka-0.4.4}/etl/config.py +0 -0
  21. {amochka-0.4.1 → amochka-0.4.4}/etl/extractors.py +0 -0
  22. {amochka-0.4.1 → amochka-0.4.4}/etl/loaders.py +0 -0
  23. {amochka-0.4.1 → amochka-0.4.4}/etl/migrations/001_create_tables.sql +0 -0
  24. {amochka-0.4.1 → amochka-0.4.4}/etl/run_etl.py +0 -0
  25. {amochka-0.4.1 → amochka-0.4.4}/etl/transformers.py +0 -0
  26. {amochka-0.4.1 → amochka-0.4.4}/setup.cfg +0 -0
  27. {amochka-0.4.1 → amochka-0.4.4}/tests/test_cache.py +0 -0
  28. {amochka-0.4.1 → amochka-0.4.4}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.4.1
3
+ Version: 0.4.4
4
4
  Summary: Python library for working with amoCRM API with ETL capabilities
5
5
  Author-email: Timur <timurdt@gmail.com>
6
6
  License: MIT
@@ -2,7 +2,7 @@
2
2
  amochka: Библиотека для работы с API amoCRM.
3
3
  """
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.4.4"
6
6
 
7
7
  from .client import AmoCRMClient, CacheConfig
8
8
  from .errors import (
@@ -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
- return response.json()
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
- data = resp.json() or {}
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
- return int(datetime.fromisoformat(value).timestamp())
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
- if note_type is not None:
1587
- params["filter[note_type]"] = note_type
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.warning(f"Empty response for notes {entity} {entity_id}, stopping pagination")
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.warning(f"Empty response for events {entity} {entity_id}, stopping pagination")
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.4.1
3
+ Version: 0.4.4
4
4
  Summary: Python library for working with amoCRM API with ETL capabilities
5
5
  Author-email: Timur <timurdt@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "amochka"
7
- version = "0.4.1"
7
+ version = "0.4.4"
8
8
  description = "Python library for working with amoCRM API with ETL capabilities"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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