diff --git a/.gitignore b/.gitignore index 9817cce..2a5e7a5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .ruff_cache/ .env coverage/ +.coverage diff --git a/README.md b/README.md index 5246891..52b2ab3 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,20 @@ base_rep = client.get_reputation("0x1234...", chain="base") result = client.assess("0x1234...", policy={"min_grade": "B", "min_score": 35}) print(result["decision"], result["decision_reasons"]) -# Browse agents -agents = client.get_agents(chain="base", limit=10) -print(len(agents["items"]), agents["count"]) - -# Ecosystem stats -stats = client.get_stats() -print(stats["erc8004"]["known_agents"]) +# Compliance assessment with verification policy +gated = client.assess("0x1234...", policy={ + "require_kyc": True, + "require_sanctions_clear": True, + "min_age": 21, +}) + +if gated["decision"] == "deny": + print(gated["decision_reasons"]) # ["kyc_required"] + print(gated.get("verify_url")) # URL for operator verification + +# Check verification level +rep = client.get_reputation("0x1234...") +print(rep.get("verification_level")) # "none" | "wallet_claimed" | "kyc_verified" ``` ### Async diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 346a821..9a788f3 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -3,40 +3,30 @@ from agentscore.client import AgentScore from agentscore.errors import AgentScoreError from agentscore.types import ( - AgentRecord, - AgentsListResponse, AssessResponse, DecisionPolicy, EntityType, Grade, + OperatorVerification, Reputation, ReputationResponse, - ReputationResponseFull, ReputationStatus, - StatsERC8004, - StatsPayments, - StatsReputation, - StatsResponse, + VerificationLevel, ) __version__ = _pkg_version("agentscore-py") __all__ = [ - "AgentRecord", "AgentScore", "AgentScoreError", - "AgentsListResponse", "AssessResponse", "DecisionPolicy", "EntityType", "Grade", + "OperatorVerification", "Reputation", "ReputationResponse", - "ReputationResponseFull", "ReputationStatus", - "StatsERC8004", - "StatsPayments", - "StatsReputation", - "StatsResponse", + "VerificationLevel", "__version__", ] diff --git a/agentscore/client.py b/agentscore/client.py index 01852ec..a092e09 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -9,11 +9,9 @@ if TYPE_CHECKING: from agentscore.types import ( - AgentsListResponse, AssessResponse, DecisionPolicy, - ReputationResponseFull, - StatsResponse, + ReputationResponse, ) @@ -37,7 +35,7 @@ def __init__( def _headers(self) -> dict: return { "Accept": "application/json", - "Authorization": f"Bearer {self.api_key}", + "X-API-Key": self.api_key, "User-Agent": f"agentscore-py/{_pkg_version('agentscore-py')}", } @@ -86,7 +84,7 @@ def _handle_response(self, response: httpx.Response) -> dict: # --- Sync methods --- - def get_reputation(self, address: str, chain: str | None = None) -> ReputationResponseFull: + def get_reputation(self, address: str, chain: str | None = None) -> ReputationResponse: """Get cached reputation for an address (free, read-only). Optionally filter by chain.""" params: dict[str, str] = {} if chain: @@ -114,22 +112,9 @@ def assess( response = client.post("/v1/assess", json=body) return self._handle_response(response) - def get_agents(self, **filters: Any) -> AgentsListResponse: - """Browse agents (free).""" - params = {k: str(v).lower() if isinstance(v, bool) else str(v) for k, v in filters.items() if v is not None} - client = self._get_sync_client() - response = client.get("/v1/agents", params=params) - return self._handle_response(response) - - def get_stats(self) -> StatsResponse: - """Get ecosystem stats (free).""" - client = self._get_sync_client() - response = client.get("/v1/stats") - return self._handle_response(response) - # --- Async methods --- - async def aget_reputation(self, address: str, chain: str | None = None) -> ReputationResponseFull: + async def aget_reputation(self, address: str, chain: str | None = None) -> ReputationResponse: """Get cached reputation for an address (free, read-only). Optionally filter by chain.""" params: dict[str, str] = {} if chain: @@ -157,19 +142,6 @@ async def aassess( response = await client.post("/v1/assess", json=body) return self._handle_response(response) - async def aget_agents(self, **filters: Any) -> AgentsListResponse: - """Browse agents (free).""" - params = {k: str(v).lower() if isinstance(v, bool) else str(v) for k, v in filters.items() if v is not None} - client = self._get_async_client() - response = await client.get("/v1/agents", params=params) - return self._handle_response(response) - - async def aget_stats(self) -> StatsResponse: - """Get ecosystem stats (free).""" - client = self._get_async_client() - response = await client.get("/v1/stats") - return self._handle_response(response) - def close(self): if self._sync_client: self._sync_client.close() diff --git a/agentscore/types.py b/agentscore/types.py index 9c99a86..c1ef3a9 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -4,7 +4,8 @@ Grade = Literal["A", "B", "C", "D", "F"] EntityType = Literal["agent", "service", "hybrid", "wallet", "bot", "unknown"] -ReputationStatus = Literal["unknown", "known_unscored", "scored", "stale", "indexing"] +ReputationStatus = Literal["scored", "stale", "known_unscored"] +VerificationLevel = Literal["none", "wallet_claimed", "kyc_verified"] class Subject(TypedDict): @@ -23,23 +24,26 @@ class Classification(TypedDict): class Score(TypedDict): - value: int | None + value: float | None grade: Grade | None scored_at: str | None status: ReputationStatus version: str -class ChainScore(TypedDict): - value: int | None +class _ChainScoreRequired(TypedDict): + value: float | None grade: Grade | None - confidence: float | None - dimensions: dict[str, float] | None scored_at: str | None status: ReputationStatus version: str +class ChainScore(_ChainScoreRequired, total=False): + confidence: float | None + dimensions: dict[str, float] | None + + class Identity(TypedDict): ens_name: str | None website_url: str | None @@ -71,36 +75,27 @@ class Reputation(TypedDict): last_feedback_at: str | None -class EvidenceSummary(TypedDict, total=False): - candidate_tx_count: int - confirmed_or_likely_tx: int - endpoint_count: int - github_stars: int - github_url: str | None - has_a2a_agent_card: bool - has_ens: bool - has_github: bool - has_website: bool - healthy_endpoints: int - is_erc8004: bool +class EvidenceSummary(TypedDict): metadata_kind: str | None - metadata_quality: str | None - multi_chain_count: int - reputation_feedback_count: int - reputation_trust_avg: float | None - reputation_uptime_avg: float | None - reputation_activity_avg: float | None - reputation_client_count: int - verified_tx_count: int + has_a2a_agent_card: bool + website_url: str | None + website_reachable: bool website_mentions_mcp: bool website_mentions_x402: bool - website_reachable: bool - website_url: str | None + github_url: str | None + github_stars: int | None -class OperatorScore(TypedDict): - score: int +class RedactedClassification(TypedDict): + entity_type: EntityType + + +class _OperatorScoreRequired(TypedDict): + score: float grade: Grade + + +class OperatorScore(_OperatorScoreRequired, total=False): agent_count: int chains_active: list[str] @@ -109,20 +104,23 @@ class AgentSummary(TypedDict): token_id: int chain: str name: str | None - score: int + score: float grade: Grade -class ChainEntry(TypedDict): +class _ChainEntryRequired(TypedDict): chain: str score: ChainScore - classification: Classification + classification: Classification | RedactedClassification + + +class ChainEntry(_ChainEntryRequired, total=False): identity: Identity activity: Activity evidence_summary: EvidenceSummary -class ReputationResponse(TypedDict): +class _ReputationResponseRequired(TypedDict): subject: Subject score: Score chains: list[ChainEntry] @@ -131,19 +129,35 @@ class ReputationResponse(TypedDict): updated_at: str | None -class ReputationResponseFull(ReputationResponse, total=False): +class ReputationResponse(_ReputationResponseRequired, total=False): + reputation: Reputation operator_score: OperatorScore agents: list[AgentSummary] - reputation: Reputation + verification_level: VerificationLevel + + +class _OperatorVerificationRequired(TypedDict): + level: VerificationLevel + + +class OperatorVerification(_OperatorVerificationRequired, total=False): + operator_type: str | None + claimed_at: str | None + verified_at: str | None class DecisionPolicy(TypedDict, total=False): min_grade: Grade min_score: int require_verified_payment_activity: bool + require_kyc: bool + require_sanctions_clear: bool + min_age: int + blocked_jurisdictions: list[str] + require_entity_type: str -class AssessResponse(TypedDict): +class _AssessResponseRequired(TypedDict): subject: Subject score: Score chains: list[ChainEntry] @@ -153,64 +167,12 @@ class AssessResponse(TypedDict): data_semantics: str caveats: list[str] updated_at: str | None + + +class AssessResponse(_AssessResponseRequired, total=False): operator_score: OperatorScore | None reputation: Reputation | None agents: list[AgentSummary] - - -class AgentRecord(TypedDict): - chain: str - token_id: int - owner_address: str - agent_wallet: str | None - name: str | None - description: str | None - metadata_quality: str - score: int | None - grade: Grade | None - entity_type: EntityType | None - endpoint_count: int - website_url: str | None - github_url: str | None - has_candidate_payment_activity: bool - has_verified_payment_activity: bool - agents_sharing_owner: int | None - updated_at: str - - -class AgentsListResponse(TypedDict): - items: list[AgentRecord] - next_cursor: str | None - count: int - version: str - - -class StatsPayments(TypedDict): - addresses_with_candidate_payment_activity: int - addresses_with_verified_payment_activity: int - total_candidate_transactions: int - total_verified_transactions: int - verification_status_summary: dict[str, int] - - -class StatsERC8004(TypedDict): - known_agents: int - by_chain: dict[str, int] - metadata_quality_distribution: dict[str, int] - - -class StatsReputation(TypedDict): - total_addresses: int - scored_addresses: int - entity_distribution: dict[str, int] - score_distribution: dict[str, int] - - -class StatsResponse(TypedDict, total=False): - version: str - as_of_time: str - data_semantics: str - erc8004: StatsERC8004 - reputation: StatsReputation - payments: StatsPayments - caveats: list[str] + operator_verification: OperatorVerification + resolved_operator: str + verify_url: str diff --git a/pyproject.toml b/pyproject.toml index 79b6c16..4e09ae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "1.3.0" +version = "1.4.0" description = "Python client for the AgentScore trust and reputation API" readme = "README.md" license = "MIT" @@ -44,4 +44,7 @@ dev = [ "ruff>=0.11.0", "vulture>=2.15", "pytest-cov>=6.0", + "respx>=0.22.0", + "pytest-asyncio>=1.2.0", + "python-dotenv>=1.2.1", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bf6bd6c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +from dotenv import load_dotenv + +load_dotenv() diff --git a/tests/test_client.py b/tests/test_client.py index dd92d81..95f7b45 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -220,141 +220,26 @@ def test_assess_raises_on_402(): # --------------------------------------------------------------------------- -# get_agents +# API key header # --------------------------------------------------------------------------- -AGENTS_PAYLOAD = { - "items": [ - { - "chain": "base", - "token_id": 1, - "owner_address": "0xowner", - "agent_wallet": None, - "name": "Test Agent", - "description": None, - "metadata_quality": "complete", - "score": 80, - "grade": "A", - "entity_type": "agent", - "endpoint_count": 1, - "website_url": None, - "github_url": None, - "has_candidate_payment_activity": True, - "has_verified_payment_activity": True, - "agents_sharing_owner": None, - "updated_at": "2024-01-01T00:00:00Z", - } - ], - "next_cursor": None, - "count": 1, - "version": "1", -} - - -@respx.mock -def test_get_agents_success(): - respx.get(f"{BASE_URL}/v1/agents").mock(return_value=httpx.Response(200, json=AGENTS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - result = client.get_agents() - assert result["count"] == 1 - assert result["items"][0]["name"] == "Test Agent" - - -@respx.mock -def test_get_agents_passes_filters(): - route = respx.get(f"{BASE_URL}/v1/agents").mock(return_value=httpx.Response(200, json=AGENTS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - client.get_agents(chain="base", grade="A") - url_str = str(route.calls.last.request.url) - assert "chain=base" in url_str - assert "grade=A" in url_str - - -@respx.mock -def test_get_agents_boolean_query_param_serialization(): - route = respx.get(f"{BASE_URL}/v1/agents").mock(return_value=httpx.Response(200, json=AGENTS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - client.get_agents(has_verified_payment_activity=True, has_endpoint=False) - url_str = str(route.calls.last.request.url) - assert "has_verified_payment_activity=true" in url_str - assert "has_endpoint=false" in url_str - - -@respx.mock -def test_get_agents_raises_on_error(): - respx.get(f"{BASE_URL}/v1/agents").mock( - return_value=httpx.Response( - 500, - json={"error": {"code": "server_error", "message": "Unexpected error"}}, - ) - ) - client = AgentScore(api_key=API_KEY) - with pytest.raises(AgentScoreError) as exc_info: - client.get_agents() - assert exc_info.value.status_code == 500 - - -# --------------------------------------------------------------------------- -# get_stats -# --------------------------------------------------------------------------- - -STATS_PAYLOAD = { - "version": "1", - "as_of_time": "2024-01-01T00:00:00Z", +REPUTATION_PAYLOAD_SIMPLE = { + "subject": {"chains": ["base"], "address": ADDRESS}, + "score": {"value": 75, "grade": "B", "scored_at": "2024-01-01T00:00:00Z", "status": "scored", "version": "1"}, "data_semantics": "live", - "erc8004": {"known_agents": 42, "by_chain": {"base": 42}, "metadata_quality_distribution": {}}, - "reputation": { - "total_addresses": 1000, - "scored_addresses": 500, - "entity_distribution": {}, - "score_distribution": {}, - }, - "payments": { - "addresses_with_candidate_payment_activity": 200, - "addresses_with_verified_payment_activity": 100, - "total_candidate_transactions": 5000, - "total_verified_transactions": 3000, - "verification_status_summary": {}, - }, "caveats": [], + "updated_at": "2024-01-01T00:00:00Z", } -@respx.mock -def test_get_stats_success(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - result = client.get_stats() - assert result["erc8004"]["known_agents"] == 42 - assert result["reputation"]["total_addresses"] == 1000 - - -@respx.mock -def test_get_stats_raises_on_error(): - respx.get(f"{BASE_URL}/v1/stats").mock( - return_value=httpx.Response( - 503, - json={"error": {"code": "service_unavailable", "message": "Service down"}}, - ) - ) - client = AgentScore(api_key=API_KEY) - with pytest.raises(AgentScoreError) as exc_info: - client.get_stats() - assert exc_info.value.status_code == 503 - assert exc_info.value.code == "service_unavailable" - - -# --------------------------------------------------------------------------- -# Authorization header -# --------------------------------------------------------------------------- - - @respx.mock def test_auth_header_is_sent(): - route = respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) + route = respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( + return_value=httpx.Response(200, json=REPUTATION_PAYLOAD_SIMPLE) + ) client = AgentScore(api_key="my-secret-key") - client.get_stats() - assert route.calls.last.request.headers["authorization"] == "Bearer my-secret-key" + client.get_reputation(ADDRESS) + assert route.calls.last.request.headers["x-api-key"] == "my-secret-key" # --------------------------------------------------------------------------- @@ -364,10 +249,12 @@ def test_auth_header_is_sent(): @respx.mock def test_error_missing_error_key_falls_back(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(400, json={"message": "bad request"})) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( + return_value=httpx.Response(400, json={"message": "bad request"}) + ) client = AgentScore(api_key=API_KEY) with pytest.raises(AgentScoreError) as exc_info: - client.get_stats() + client.get_reputation(ADDRESS) assert exc_info.value.status_code == 400 assert exc_info.value.code == "unknown_error" @@ -433,38 +320,6 @@ async def test_aassess_success(): await client.aclose() -# --------------------------------------------------------------------------- -# Async: aget_agents -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_aget_agents_success(): - respx.get(f"{BASE_URL}/v1/agents").mock(return_value=httpx.Response(200, json=AGENTS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - result = await client.aget_agents() - assert result["count"] == 1 - assert result["items"][0]["name"] == "Test Agent" - await client.aclose() - - -# --------------------------------------------------------------------------- -# Async: aget_stats -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@respx.mock -async def test_aget_stats_success(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - result = await client.aget_stats() - assert result["erc8004"]["known_agents"] == 42 - assert result["reputation"]["total_addresses"] == 1000 - await client.aclose() - - # --------------------------------------------------------------------------- # Context managers and close # --------------------------------------------------------------------------- @@ -472,34 +327,32 @@ async def test_aget_stats_success(): @respx.mock def test_sync_context_manager(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(return_value=httpx.Response(200, json=REPUTATION_PAYLOAD)) with AgentScore(api_key=API_KEY) as client: - result = client.get_stats() - assert result["erc8004"]["known_agents"] == 42 - # After exiting, sync client should be closed + result = client.get_reputation(ADDRESS) + assert result["score"]["grade"] == "B" assert client._sync_client is None @pytest.mark.asyncio @respx.mock async def test_async_context_manager(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(return_value=httpx.Response(200, json=REPUTATION_PAYLOAD)) async with AgentScore(api_key=API_KEY) as client: - result = await client.aget_stats() - assert result["erc8004"]["known_agents"] == 42 - # After exiting, async client should be closed + result = await client.aget_reputation(ADDRESS) + assert result["score"]["grade"] == "B" assert client._async_client is None @respx.mock def test_success_response_with_invalid_json(): """A 200 response with non-JSON body should raise AgentScoreError.""" - respx.get(f"{BASE_URL}/v1/stats").mock( + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( return_value=httpx.Response(200, text="not json"), ) client = AgentScore(api_key=API_KEY) with pytest.raises(AgentScoreError) as exc_info: - client.get_stats() + client.get_reputation(ADDRESS) assert exc_info.value.code == "invalid_response" assert exc_info.value.status_code == 200 @@ -524,11 +377,11 @@ def test_user_agent_header_includes_version(): """User-Agent header should include package version.""" from importlib.metadata import version - respx.get(f"{BASE_URL}/v1/stats").mock( - return_value=httpx.Response(200, json={"erc8004": {"known_agents": 1}}), + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( + return_value=httpx.Response(200, json=REPUTATION_PAYLOAD), ) client = AgentScore(api_key=API_KEY) - client.get_stats() + client.get_reputation(ADDRESS) request = respx.calls[0].request assert request.headers["user-agent"] == f"agentscore-py/{version('agentscore-py')}" @@ -538,16 +391,6 @@ def test_user_agent_header_includes_version(): # --------------------------------------------------------------------------- -@respx.mock -def test_get_agents_none_values_excluded_from_params(): - route = respx.get(f"{BASE_URL}/v1/agents").mock(return_value=httpx.Response(200, json=AGENTS_PAYLOAD)) - client = AgentScore(api_key=API_KEY) - client.get_agents(chain="base", limit=None) - url_str = str(route.calls.last.request.url) - assert "chain=base" in url_str - assert "limit" not in url_str - - @respx.mock def test_assess_refresh_false_not_included_in_body(): route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) @@ -559,9 +402,9 @@ def test_assess_refresh_false_not_included_in_body(): @respx.mock def test_double_close(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(return_value=httpx.Response(200, json=REPUTATION_PAYLOAD)) client = AgentScore(api_key=API_KEY) - client.get_stats() + client.get_reputation(ADDRESS) client.close() client.close() assert client._sync_client is None @@ -570,9 +413,9 @@ def test_double_close(): @pytest.mark.asyncio @respx.mock async def test_double_aclose(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(200, json=STATS_PAYLOAD)) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(return_value=httpx.Response(200, json=REPUTATION_PAYLOAD)) client = AgentScore(api_key=API_KEY) - await client.aget_stats() + await client.aget_reputation(ADDRESS) await client.aclose() await client.aclose() assert client._async_client is None @@ -618,25 +461,190 @@ def test_assess_empty_policy_dict_included_in_body(): @respx.mock def test_timeout_error_raises_agentscore_error(): - respx.get(f"{BASE_URL}/v1/stats").mock(side_effect=httpx.TimeoutException("timed out")) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(side_effect=httpx.TimeoutException("timed out")) client = AgentScore(api_key=API_KEY) with pytest.raises(httpx.TimeoutException): - client.get_stats() + client.get_reputation(ADDRESS) @respx.mock def test_connect_error_raises_agentscore_error(): - respx.get(f"{BASE_URL}/v1/stats").mock(side_effect=httpx.ConnectError("connection refused")) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(side_effect=httpx.ConnectError("connection refused")) client = AgentScore(api_key=API_KEY) with pytest.raises(httpx.ConnectError): - client.get_stats() + client.get_reputation(ADDRESS) @respx.mock def test_error_response_no_error_key_fallback(): - respx.get(f"{BASE_URL}/v1/stats").mock(return_value=httpx.Response(422, json={"detail": "validation failed"})) + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( + return_value=httpx.Response(422, json={"detail": "validation failed"}) + ) client = AgentScore(api_key=API_KEY) with pytest.raises(AgentScoreError) as exc_info: - client.get_stats() + client.get_reputation(ADDRESS) assert exc_info.value.status_code == 422 assert exc_info.value.code == "unknown_error" + + +# --------------------------------------------------------------------------- +# Verification / Compliance fields +# --------------------------------------------------------------------------- + + +REPUTATION_WITH_VERIFICATION = { + **REPUTATION_PAYLOAD, + "verification_level": "kyc_verified", +} + +ASSESS_WITH_COMPLIANCE = { + **ASSESS_PAYLOAD, + "decision": "deny", + "decision_reasons": ["kyc_required", "sanctions_check_pending"], + "operator_verification": { + "level": "none", + "operator_type": None, + "claimed_at": None, + "verified_at": None, + }, + "verify_url": "https://agentscore.sh/verify/abc123", + "resolved_operator": "0xoperator456", +} + + +@respx.mock +def test_get_reputation_returns_verification_level(): + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( + return_value=httpx.Response(200, json=REPUTATION_WITH_VERIFICATION) + ) + client = AgentScore(api_key=API_KEY) + result = client.get_reputation(ADDRESS) + assert result["verification_level"] == "kyc_verified" + + +@respx.mock +def test_get_reputation_omits_verification_level_when_absent(): + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(return_value=httpx.Response(200, json=REPUTATION_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = client.get_reputation(ADDRESS) + assert "verification_level" not in result + + +@respx.mock +def test_assess_returns_operator_verification(): + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_WITH_COMPLIANCE)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS) + assert result["operator_verification"]["level"] == "none" + assert result["operator_verification"]["operator_type"] is None + + +@respx.mock +def test_assess_returns_verify_url(): + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_WITH_COMPLIANCE)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS) + assert result["verify_url"] == "https://agentscore.sh/verify/abc123" + + +@respx.mock +def test_assess_returns_resolved_operator(): + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_WITH_COMPLIANCE)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS) + assert result["resolved_operator"] == "0xoperator456" + + +@respx.mock +def test_assess_omits_verification_fields_when_absent(): + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS) + assert "operator_verification" not in result + assert "verify_url" not in result + assert "resolved_operator" not in result + + +@respx.mock +def test_assess_sends_compliance_policy_fields(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + policy = { + "require_kyc": True, + "require_sanctions_clear": True, + "min_age": 90, + "blocked_jurisdictions": ["KP", "IR"], + "require_entity_type": "agent", + } + client.assess(ADDRESS, policy=policy) + body = json.loads(route.calls.last.request.content) + assert body["policy"]["require_kyc"] is True + assert body["policy"]["require_sanctions_clear"] is True + assert body["policy"]["min_age"] == 90 + assert body["policy"]["blocked_jurisdictions"] == ["KP", "IR"] + assert body["policy"]["require_entity_type"] == "agent" + + +@pytest.mark.asyncio +@respx.mock +async def test_aget_reputation_returns_verification_level(): + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock( + return_value=httpx.Response(200, json=REPUTATION_WITH_VERIFICATION) + ) + client = AgentScore(api_key=API_KEY) + result = await client.aget_reputation(ADDRESS) + assert result["verification_level"] == "kyc_verified" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_returns_compliance_fields(): + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_WITH_COMPLIANCE)) + client = AgentScore(api_key=API_KEY) + result = await client.aassess(ADDRESS) + assert result["operator_verification"]["level"] == "none" + assert result["verify_url"] == "https://agentscore.sh/verify/abc123" + assert result["resolved_operator"] == "0xoperator456" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Integration-style: compliance deny flow +# --------------------------------------------------------------------------- + + +@respx.mock +def test_full_compliance_deny_flow(): + """Full assess flow with compliance policy returning deny + verify_url.""" + compliance_response = { + **REPUTATION_PAYLOAD, + "decision": "deny", + "decision_reasons": ["kyc_required", "sanctions_check_pending"], + "on_the_fly": False, + "operator_verification": { + "level": "none", + "operator_type": None, + "claimed_at": None, + "verified_at": None, + }, + "verify_url": "https://agentscore.sh/verify/xyz789", + } + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=compliance_response)) + client = AgentScore(api_key=API_KEY) + result = client.assess( + ADDRESS, + policy={ + "require_kyc": True, + "require_sanctions_clear": True, + }, + ) + assert result["decision"] == "deny" + assert "kyc_required" in result["decision_reasons"] + assert "sanctions_check_pending" in result["decision_reasons"] + assert result["verify_url"] == "https://agentscore.sh/verify/xyz789" + assert result["operator_verification"]["level"] == "none" + + body = json.loads(route.calls.last.request.content) + assert body["policy"]["require_kyc"] is True + assert body["policy"]["require_sanctions_clear"] is True diff --git a/tests/test_integration.py b/tests/test_integration.py index 5a7ed38..d1afd9a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -104,16 +104,6 @@ def test_get_reputation_operator_score(): assert isinstance(op["chains_active"], list) -def test_get_reputation_reputation_field(): - client = AgentScore(api_key=API_KEY, base_url=BASE_URL) - rep = client.get_reputation(TEST_ADDRESS) - r = rep.get("reputation") - if not r: - pytest.skip("no reputation on test address") - assert isinstance(r["feedback_count"], int) - assert isinstance(r["client_count"], int) - - def test_assess_then_get_reputation(): client = AgentScore(api_key=API_KEY, base_url=BASE_URL) assessed = client.assess(TEST_ADDRESS) @@ -123,16 +113,3 @@ def test_assess_then_get_reputation(): assert "value" in rep["score"] assert isinstance(rep["score"]["value"], (int, float)) assert rep["subject"]["address"].lower() == TEST_ADDRESS.lower() - - -def test_get_agents_items_have_expected_fields(): - client = AgentScore(api_key=API_KEY, base_url=BASE_URL) - result = client.get_agents() - - assert isinstance(result["items"], list) - assert len(result["items"]) > 0 - - for item in result["items"]: - assert "chain" in item - assert "token_id" in item - assert "owner_address" in item diff --git a/uv.lock b/uv.lock index 7018c88..125e147 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agentscore-py" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -25,7 +25,12 @@ dev = [ [package.dev-dependencies] dev = [ + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov" }, + { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "respx" }, { name = "ruff" }, { name = "vulture" }, ] @@ -41,7 +46,10 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=6.0" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "respx", specifier = ">=0.22.0" }, { name = "ruff", specifier = ">=0.11.0" }, { name = "vulture", specifier = ">=2.15" }, ] @@ -549,6 +557,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "respx" version = "0.22.0"