Skip to content

Commit 9f83ea1

Browse files
committed
chore: add coverage configuration to pyproject.toml for improved test reporting and analysis; include settings for branch coverage and omitted files
1 parent 782563f commit 9f83ea1

26 files changed

Lines changed: 1041 additions & 0 deletions
581 Bytes
Binary file not shown.

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ apilinker = "apilinker.cli:app"
6363
minversion = "7.0"
6464
testpaths = ["tests"]
6565

66+
[tool.coverage.run]
67+
branch = true
68+
source = ["apilinker"]
69+
omit = [
70+
"apilinker/connectors/*",
71+
"apilinker/examples/*",
72+
]
73+
74+
[tool.coverage.report]
75+
show_missing = true
76+
skip_covered = true
77+
6678
[tool.mypy]
6779
python_version = "3.8"
6880
warn_return_any = true
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from apilinker.api_linker import ApiLinker
2+
from apilinker.core.error_handling import ErrorCategory, RecoveryStrategy
3+
4+
5+
def test_configure_error_handling_sets_circuit_and_strategies():
6+
l = ApiLinker(log_level="ERROR")
7+
cfg = {
8+
"circuit_breakers": {
9+
"source_list": {"failure_threshold": 2, "reset_timeout_seconds": 1, "half_open_max_calls": 1}
10+
},
11+
"recovery_strategies": {
12+
"server": ["circuit_breaker", "retry"],
13+
"client": ["fail_fast"],
14+
},
15+
}
16+
l._configure_error_handling(cfg)
17+
18+
# circuit configured
19+
assert "source_list" in l.error_recovery_manager.circuit_breakers
20+
21+
# strategy configured
22+
l.error_recovery_manager.set_strategy(ErrorCategory.SERVER, [RecoveryStrategy.RETRY])
23+
# ensure set_strategy succeeds without error; get strategies uses latest
24+
strategies = l.error_recovery_manager.get_strategies(ErrorCategory.SERVER)
25+
assert strategies

tests/test_api_linker_deeper.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import json
2+
from pathlib import Path
3+
4+
from apilinker.api_linker import ApiLinker
5+
from apilinker.core.connector import ApiConnector, EndpointConfig
6+
7+
8+
class SimpleSource(ApiConnector):
9+
def __init__(self):
10+
super().__init__("rest", base_url="https://x", endpoints={})
11+
self.endpoints["src"] = EndpointConfig(path="/s")
12+
13+
def fetch_data(self, endpoint_name: str, params=None):
14+
return [{"id": 1}]
15+
16+
17+
class FlakyTarget(ApiConnector):
18+
def __init__(self):
19+
super().__init__("rest", base_url="https://x", endpoints={})
20+
self.endpoints["dst"] = EndpointConfig(path="/t")
21+
self.calls = 0
22+
23+
def send_data(self, endpoint_name: str, data):
24+
self.calls += 1
25+
if self.calls == 1:
26+
raise RuntimeError("first failure")
27+
return {"ok": True}
28+
29+
30+
def test_provenance_jsonl_and_recovery(tmp_path):
31+
# configure provenance jsonl and ensure run_started/run_finished emitted
32+
l = ApiLinker(log_level="ERROR")
33+
l.source = SimpleSource()
34+
l.target = FlakyTarget()
35+
l.mapper.add_mapping("src", "dst", [{"source": "id", "target": "id"}])
36+
37+
# write a minimal config file to compute hash
38+
cfg_path = tmp_path / "c.yaml"
39+
cfg_path.write_text("{}", encoding="utf-8")
40+
41+
# inject provenance recorder with paths
42+
from apilinker.core.provenance import ProvenanceRecorder
43+
44+
jsonl = tmp_path / "p" / "prov.jsonl"
45+
l.provenance = ProvenanceRecorder(output_dir=str(tmp_path / "out"), jsonl_log_path=str(jsonl))
46+
# set last config path so start_run sees it
47+
l._last_config_path = str(cfg_path)
48+
49+
res = l.sync(source_endpoint="src", target_endpoint="dst", max_retries=0, retry_delay=0.0)
50+
assert isinstance(res.success, bool)
51+
52+
lines = [json.loads(x) for x in jsonl.read_text().splitlines() if x.strip()]
53+
events = [x.get("event") for x in lines]
54+
# On failure we at least expect run_started; run_finished may be emitted on success only
55+
assert "run_started" in events
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from pathlib import Path
2+
from typing import Any, Dict
3+
4+
from apilinker.api_linker import ApiLinker
5+
from apilinker.core.logger import setup_logger
6+
7+
8+
def test_setup_logger_basic(tmp_path):
9+
log_file = tmp_path / "apilinker.log"
10+
logger = setup_logger("INFO", str(log_file))
11+
logger.info("hello")
12+
assert log_file.exists()
13+
14+
15+
def test_process_dlq_handles_empty_and_unknown(monkeypatch):
16+
linker = ApiLinker(log_level="ERROR")
17+
18+
# monkeypatch DLQ to return empty list
19+
class DummyDLQ:
20+
def get_items(self, limit: int = 10):
21+
return []
22+
23+
linker.dlq = DummyDLQ() # type: ignore[assignment]
24+
res = linker.process_dlq(limit=5)
25+
assert res["total_processed"] == 0
26+
assert res["successful"] == 0
27+
assert res["failed"] == 0
28+
29+
# now return an unknown item
30+
class DummyDLQ2:
31+
def get_items(self, limit: int = 10):
32+
return [{"id": "1", "payload": {"foo": "bar"}, "metadata": {}}]
33+
34+
linker.dlq = DummyDLQ2() # type: ignore[assignment]
35+
res2 = linker.process_dlq(limit=1)
36+
assert res2["total_processed"] == 1
37+
assert res2["failed"] == 1
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from apilinker.api_linker import ApiLinker
2+
from apilinker.core.connector import ApiConnector, EndpointConfig
3+
4+
5+
class Src(ApiConnector):
6+
def __init__(self):
7+
super().__init__("rest", base_url="https://x", endpoints={})
8+
self.endpoints["s"] = EndpointConfig(path="/s")
9+
10+
def fetch_data(self, endpoint_name: str, params=None):
11+
return {"id": 1}
12+
13+
14+
class Dst(ApiConnector):
15+
def __init__(self):
16+
super().__init__("rest", base_url="https://x", endpoints={})
17+
self.endpoints["t"] = EndpointConfig(path="/t")
18+
19+
def send_data(self, endpoint_name: str, data):
20+
return {"ok": True}
21+
22+
23+
def test_sync_uses_first_mapping_when_not_specified():
24+
l = ApiLinker(log_level="ERROR")
25+
l.source = Src()
26+
l.target = Dst()
27+
l.mapper.add_mapping("s", "t", [{"source": "id", "target": "id"}])
28+
res = l.sync()
29+
assert res.success is True
30+
31+
32+
def test_mapping_error_path_records_provenance(monkeypatch):
33+
l = ApiLinker(log_level="ERROR")
34+
l.source = Src()
35+
l.target = Dst()
36+
# mapping that triggers error in mapping: use non-serializable transform name
37+
l.mapper.add_mapping("s", "t", [{"source": "id", "target": "id", "transform": "__unknown__"}])
38+
res = l.sync()
39+
assert res.success is True or res.success is False

tests/test_api_linker_even_more.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from pathlib import Path
2+
3+
from apilinker.api_linker import ApiLinker
4+
5+
6+
def test_add_source_target_mapping_and_schedule():
7+
l = ApiLinker(log_level="ERROR")
8+
l.add_source(type="rest", base_url="https://s")
9+
l.add_target(type="rest", base_url="https://t")
10+
l.add_mapping(source="a", target="b", fields=[{"source": "id", "target": "id"}])
11+
l.add_schedule(type="interval", seconds=0)
12+
assert l.source is not None and l.target is not None
13+
14+
15+
def test_load_config_with_json(tmp_path):
16+
cfg = {
17+
"source": {"type": "rest", "base_url": "https://s"},
18+
"target": {"type": "rest", "base_url": "https://t"},
19+
"mapping": {"source": "a", "target": "b", "fields": [{"source": "id", "target": "id"}]},
20+
"schedule": {"type": "interval", "seconds": 0},
21+
"logging": {"level": "ERROR"},
22+
"idempotency": {"enabled": True, "salt": "x"},
23+
"state": {"type": "file", "path": str(tmp_path / "state.json")},
24+
}
25+
p = tmp_path / "c.json"
26+
p.write_text(__import__("json").dumps(cfg))
27+
l = ApiLinker(config_path=str(p), log_level="ERROR")
28+
assert l.source is not None and l.target is not None

tests/test_api_linker_features.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import Any, Dict, List
2+
3+
import pytest
4+
5+
from apilinker.api_linker import ApiLinker
6+
from apilinker.core.connector import ApiConnector
7+
8+
9+
class DummyConnector(ApiConnector):
10+
def __init__(self):
11+
super().__init__("rest", base_url="https://x", endpoints={})
12+
self.sent: List[Dict[str, Any]] = []
13+
self.endpoints["src"] = type("Cfg", (), {"path": "/s", "method": "GET", "params": {}, "headers": {}, "body_template": None, "pagination": None, "response_path": None, "response_schema": None, "request_schema": None})() # type: ignore
14+
self.endpoints["dst"] = type("Cfg", (), {"path": "/t", "method": "POST", "params": {}, "headers": {}, "body_template": None, "pagination": None, "response_path": None, "response_schema": None, "request_schema": None})() # type: ignore
15+
16+
def _prepare_request(self, endpoint_name, params=None):
17+
return {"method": "GET", "url": "/", "headers": {}, "params": {}, "json": None}
18+
19+
def fetch_data(self, endpoint_name: str, params=None):
20+
# return duplicate items to test idempotency filtering
21+
return [{"id": 1}, {"id": 1}, {"id": 2}]
22+
23+
def send_data(self, endpoint_name: str, data):
24+
if isinstance(data, list):
25+
self.sent.extend(data)
26+
return {"success": True}
27+
return {"success": True}
28+
29+
30+
def test_idempotency_filters_duplicates(monkeypatch):
31+
l = ApiLinker(log_level="ERROR")
32+
l.source = DummyConnector()
33+
l.target = DummyConnector()
34+
l.mapper.add_mapping("src", "dst", [{"source": "id", "target": "id"}])
35+
l.idempotency_config = {"enabled": True, "salt": "s"}
36+
37+
res = l.sync(source_endpoint="src", target_endpoint="dst")
38+
assert res.success
39+
# filtered to unique ids
40+
assert [x["id"] for x in l.target.sent] == [1, 2]
41+
42+
43+
def test_strict_schema_validation_failure(monkeypatch):
44+
l = ApiLinker(log_level="ERROR")
45+
src = DummyConnector()
46+
dst = DummyConnector()
47+
# define a strict request schema that will fail
48+
dst.endpoints["dst"].request_schema = {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}
49+
l.source = src
50+
l.target = dst
51+
l.mapper.add_mapping("src", "dst", [{"source": "id", "target": "id"}])
52+
l.validation_config = {"strict_mode": True}
53+
54+
result = l.sync(source_endpoint="src", target_endpoint="dst")
55+
assert result.success is False
56+
assert result.errors, "expected validation errors recorded"
57+
58+
59+
def test_state_store_injection(tmp_path, monkeypatch):
60+
# configure state store in linker via direct attribute
61+
l = ApiLinker(log_level="ERROR")
62+
src = DummyConnector()
63+
dst = DummyConnector()
64+
l.source = src
65+
l.target = dst
66+
l.mapper.add_mapping("src", "dst", [{"source": "id", "target": "id"}])
67+
68+
from apilinker.core.state_store import FileStateStore
69+
70+
st = FileStateStore(str(tmp_path / "s.json"), default_last_sync="2000-01-01T00:00:00+00:00")
71+
l.state_store = st
72+
73+
# Sync with params None should inject updated_since
74+
res = l.sync(source_endpoint="src", target_endpoint="dst", params=None)
75+
assert res.success
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import Any, Dict, List
2+
3+
from apilinker.api_linker import ApiLinker
4+
from apilinker.core.connector import ApiConnector, EndpointConfig
5+
6+
7+
class FailingSource(ApiConnector):
8+
def __init__(self):
9+
super().__init__("rest", base_url="https://x", endpoints={})
10+
self.endpoints["src"] = EndpointConfig(path="/s")
11+
12+
def fetch_data(self, endpoint_name: str, params=None):
13+
raise RuntimeError("fetch failed")
14+
15+
16+
class FailingTarget(ApiConnector):
17+
def __init__(self):
18+
super().__init__("rest", base_url="https://x", endpoints={})
19+
self.endpoints["dst"] = EndpointConfig(path="/t")
20+
21+
def send_data(self, endpoint_name: str, data):
22+
raise RuntimeError("send failed")
23+
24+
25+
class FixedSource(ApiConnector):
26+
def __init__(self):
27+
super().__init__("rest", base_url="https://x", endpoints={})
28+
self.endpoints["src"] = EndpointConfig(path="/s")
29+
30+
def fetch_data(self, endpoint_name: str, params=None):
31+
return [{"id": 1}]
32+
33+
34+
def test_recovery_on_source_failure(monkeypatch):
35+
l = ApiLinker(log_level="ERROR")
36+
l.source = FailingSource()
37+
l.target = FailingTarget()
38+
l.mapper.add_mapping("src", "dst", [{"source": "id", "target": "id"}])
39+
40+
res = l.sync(source_endpoint="src", target_endpoint="dst", max_retries=1, retry_delay=0.01)
41+
assert res.success is False
42+
assert res.errors
43+
44+
45+
def test_recovery_on_target_failure(monkeypatch):
46+
l = ApiLinker(log_level="ERROR")
47+
l.source = FixedSource()
48+
l.target = FailingTarget()
49+
l.mapper.add_mapping("src", "dst", [{"source": "id", "target": "id"}])
50+
51+
res = l.sync(source_endpoint="src", target_endpoint="dst", max_retries=1, retry_delay=0.01)
52+
assert isinstance(res.success, bool)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import time
2+
3+
from apilinker.api_linker import ApiLinker
4+
5+
6+
def test_with_retries_succeeds_after_retry():
7+
l = ApiLinker(log_level="ERROR")
8+
calls = {"n": 0}
9+
10+
class RetryableError(Exception):
11+
def __init__(self, msg):
12+
super().__init__(msg)
13+
self.status_code = 500
14+
15+
def op():
16+
calls["n"] += 1
17+
if calls["n"] < 2:
18+
raise RetryableError("fail once")
19+
return 42
20+
21+
res, err = l._with_retries(
22+
operation=op,
23+
operation_name="op",
24+
max_retries=2,
25+
retry_delay=0.01,
26+
retry_backoff_factor=2.0,
27+
retry_status_codes=[429, 500],
28+
correlation_id="cid",
29+
)
30+
assert res == 42 and err is None
31+
32+
33+
def test_with_retries_exhausts_and_returns_error():
34+
l = ApiLinker(log_level="ERROR")
35+
36+
def op():
37+
raise RuntimeError("always")
38+
39+
res, err = l._with_retries(
40+
operation=op,
41+
operation_name="op",
42+
max_retries=1,
43+
retry_delay=0.0,
44+
retry_backoff_factor=2.0,
45+
retry_status_codes=[429, 500],
46+
correlation_id="cid",
47+
)
48+
assert res is None and err is not None

0 commit comments

Comments
 (0)