malloryapi 0.1.0__py3-none-any.whl

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.
malloryapi/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """malloryapi - Official Python client for the Mallory API."""
2
+
3
+ from malloryapi._pagination import paginate_async, paginate_sync
4
+ from malloryapi._types import PaginatedResponse
5
+ from malloryapi.client import AsyncMalloryApi, MalloryApi
6
+ from malloryapi.exceptions import (
7
+ APIError,
8
+ AuthenticationError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ValidationError,
12
+ )
13
+
14
+ __all__ = [
15
+ "MalloryApi",
16
+ "AsyncMalloryApi",
17
+ "PaginatedResponse",
18
+ "paginate_sync",
19
+ "paginate_async",
20
+ "APIError",
21
+ "AuthenticationError",
22
+ "NotFoundError",
23
+ "RateLimitError",
24
+ "ValidationError",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
malloryapi/_http.py ADDED
@@ -0,0 +1,202 @@
1
+ """HTTP client wrapper for sync and async requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from malloryapi.exceptions import (
11
+ APIError,
12
+ AuthenticationError,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ ValidationError,
16
+ )
17
+
18
+ DEFAULT_BASE_URL = "https://api.mallory.ai/v1"
19
+ DEFAULT_TIMEOUT = 30.0
20
+
21
+
22
+ def _resolve_api_key(api_key: str | None) -> str:
23
+ key = api_key or os.environ.get("MALLORY_API_KEY")
24
+ if not key:
25
+ raise AuthenticationError(
26
+ "No API key provided. Pass api_key= or set the "
27
+ "MALLORY_API_KEY environment variable."
28
+ )
29
+ return key
30
+
31
+
32
+ def _build_headers(api_key: str) -> dict[str, str]:
33
+ return {
34
+ "Authorization": f"Bearer {api_key}",
35
+ "Content-Type": "application/json",
36
+ "Accept": "application/json",
37
+ }
38
+
39
+
40
+ def _handle_error_response(response: httpx.Response) -> None:
41
+ """Raise the appropriate exception for non-2xx responses."""
42
+ status = response.status_code
43
+ try:
44
+ body = response.json()
45
+ except Exception:
46
+ body = response.text
47
+
48
+ if status in (401, 403):
49
+ raise AuthenticationError(
50
+ f"Authentication failed ({status})",
51
+ status_code=status,
52
+ response_body=body,
53
+ )
54
+ if status == 404:
55
+ raise NotFoundError(
56
+ "Resource not found",
57
+ status_code=status,
58
+ response_body=body,
59
+ )
60
+ if status == 422:
61
+ raise ValidationError(
62
+ f"Validation error: {body}",
63
+ status_code=status,
64
+ response_body=body,
65
+ )
66
+ if status == 429:
67
+ raise RateLimitError(
68
+ "Rate limit exceeded",
69
+ status_code=status,
70
+ response_body=body,
71
+ )
72
+ raise APIError(
73
+ f"API request failed ({status})",
74
+ status_code=status,
75
+ response_body=body,
76
+ )
77
+
78
+
79
+ class SyncHttpClient:
80
+ """Synchronous HTTP client backed by httpx."""
81
+
82
+ def __init__(
83
+ self,
84
+ api_key: str | None = None,
85
+ base_url: str = DEFAULT_BASE_URL,
86
+ timeout: float = DEFAULT_TIMEOUT,
87
+ ) -> None:
88
+ resolved_key = _resolve_api_key(api_key)
89
+ self.base_url = base_url.rstrip("/")
90
+ self._client = httpx.Client(
91
+ base_url=self.base_url,
92
+ headers=_build_headers(resolved_key),
93
+ timeout=timeout,
94
+ )
95
+
96
+ def get(
97
+ self,
98
+ path: str,
99
+ params: dict[str, Any] | None = None,
100
+ ) -> Any:
101
+ response = self._client.get(path, params=params)
102
+ if response.status_code >= 400:
103
+ _handle_error_response(response)
104
+ return response.json()
105
+
106
+ def post(
107
+ self,
108
+ path: str,
109
+ json: Any = None,
110
+ params: dict[str, Any] | None = None,
111
+ ) -> Any:
112
+ response = self._client.post(path, json=json, params=params)
113
+ if response.status_code >= 400:
114
+ _handle_error_response(response)
115
+ return response.json()
116
+
117
+ def patch(
118
+ self,
119
+ path: str,
120
+ json: Any = None,
121
+ ) -> Any:
122
+ response = self._client.patch(path, json=json)
123
+ if response.status_code >= 400:
124
+ _handle_error_response(response)
125
+ return response.json()
126
+
127
+ def delete(
128
+ self,
129
+ path: str,
130
+ params: dict[str, Any] | None = None,
131
+ ) -> Any:
132
+ response = self._client.delete(path, params=params)
133
+ if response.status_code >= 400:
134
+ _handle_error_response(response)
135
+ return response.json()
136
+
137
+ def close(self) -> None:
138
+ self._client.close()
139
+
140
+
141
+ class AsyncHttpClient:
142
+ """Asynchronous HTTP client backed by httpx."""
143
+
144
+ def __init__(
145
+ self,
146
+ api_key: str | None = None,
147
+ base_url: str = DEFAULT_BASE_URL,
148
+ timeout: float = DEFAULT_TIMEOUT,
149
+ ) -> None:
150
+ resolved_key = _resolve_api_key(api_key)
151
+ self.base_url = base_url.rstrip("/")
152
+ self._client = httpx.AsyncClient(
153
+ base_url=self.base_url,
154
+ headers=_build_headers(resolved_key),
155
+ timeout=timeout,
156
+ )
157
+
158
+ async def get(
159
+ self,
160
+ path: str,
161
+ params: dict[str, Any] | None = None,
162
+ ) -> Any:
163
+ response = await self._client.get(path, params=params)
164
+ if response.status_code >= 400:
165
+ _handle_error_response(response)
166
+ return response.json()
167
+
168
+ async def post(
169
+ self,
170
+ path: str,
171
+ json: Any = None,
172
+ params: dict[str, Any] | None = None,
173
+ ) -> Any:
174
+ response = await self._client.post(
175
+ path, json=json, params=params
176
+ )
177
+ if response.status_code >= 400:
178
+ _handle_error_response(response)
179
+ return response.json()
180
+
181
+ async def patch(
182
+ self,
183
+ path: str,
184
+ json: Any = None,
185
+ ) -> Any:
186
+ response = await self._client.patch(path, json=json)
187
+ if response.status_code >= 400:
188
+ _handle_error_response(response)
189
+ return response.json()
190
+
191
+ async def delete(
192
+ self,
193
+ path: str,
194
+ params: dict[str, Any] | None = None,
195
+ ) -> Any:
196
+ response = await self._client.delete(path, params=params)
197
+ if response.status_code >= 400:
198
+ _handle_error_response(response)
199
+ return response.json()
200
+
201
+ async def aclose(self) -> None:
202
+ await self._client.aclose()
@@ -0,0 +1,56 @@
1
+ """Auto-pagination iterators for sync and async usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, AsyncIterator, Callable, Iterator
6
+
7
+
8
+ def paginate_sync(
9
+ fetch: Callable[..., Any],
10
+ limit: int = 100,
11
+ **kwargs: Any,
12
+ ) -> Iterator[dict[str, Any]]:
13
+ """Yield all items across pages synchronously.
14
+
15
+ Usage::
16
+
17
+ for vuln in paginate_sync(client.vulnerabilities.list, limit=50):
18
+ print(vuln["cve_id"])
19
+ """
20
+ offset = 0
21
+ while True:
22
+ page = fetch(offset=offset, limit=limit, **kwargs)
23
+ items = page.items if hasattr(page, "items") else page
24
+ if not items:
25
+ break
26
+ yield from items
27
+ offset += limit
28
+ if not page.has_more:
29
+ break
30
+
31
+
32
+ async def paginate_async(
33
+ fetch: Callable[..., Any],
34
+ limit: int = 100,
35
+ **kwargs: Any,
36
+ ) -> AsyncIterator[dict[str, Any]]:
37
+ """Yield all items across pages asynchronously.
38
+
39
+ Usage::
40
+
41
+ async for vuln in paginate_async(
42
+ client.vulnerabilities.list, limit=50
43
+ ):
44
+ print(vuln["cve_id"])
45
+ """
46
+ offset = 0
47
+ while True:
48
+ page = await fetch(offset=offset, limit=limit, **kwargs)
49
+ items = page.items if hasattr(page, "items") else page
50
+ if not items:
51
+ break
52
+ for item in items:
53
+ yield item
54
+ offset += limit
55
+ if not page.has_more:
56
+ break
malloryapi/_types.py ADDED
@@ -0,0 +1,30 @@
1
+ """Shared types for the Mallory API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class PaginatedResponse:
11
+ """Response from a paginated list endpoint."""
12
+
13
+ items: list[dict[str, Any]] = field(default_factory=list)
14
+ total: int = 0
15
+ offset: int = 0
16
+ limit: int = 100
17
+
18
+ def __len__(self) -> int:
19
+ return len(self.items)
20
+
21
+ def __iter__(self):
22
+ return iter(self.items)
23
+
24
+ def __getitem__(self, index):
25
+ return self.items[index]
26
+
27
+ @property
28
+ def has_more(self) -> bool:
29
+ """Whether there are more pages available."""
30
+ return self.offset + self.limit < self.total
malloryapi/client.py ADDED
@@ -0,0 +1,176 @@
1
+ """Main client classes for the Mallory API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from malloryapi._http import (
6
+ DEFAULT_BASE_URL,
7
+ DEFAULT_TIMEOUT,
8
+ AsyncHttpClient,
9
+ SyncHttpClient,
10
+ )
11
+ from malloryapi.resources.attack_patterns import (
12
+ AsyncAttackPatterns,
13
+ AttackPatterns,
14
+ )
15
+ from malloryapi.resources.breaches import AsyncBreaches, Breaches
16
+ from malloryapi.resources.content_chunks import (
17
+ AsyncContentChunks,
18
+ ContentChunks,
19
+ )
20
+ from malloryapi.resources.detection_signatures import (
21
+ AsyncDetectionSignatures,
22
+ DetectionSignatures,
23
+ )
24
+ from malloryapi.resources.exploitations import (
25
+ AsyncExploitations,
26
+ Exploitations,
27
+ )
28
+ from malloryapi.resources.exploits import AsyncExploits, Exploits
29
+ from malloryapi.resources.malware import AsyncMalware, Malware
30
+ from malloryapi.resources.mentions import AsyncMentions, Mentions
31
+ from malloryapi.resources.organizations import (
32
+ AsyncOrganizations,
33
+ Organizations,
34
+ )
35
+ from malloryapi.resources.products import AsyncProducts, Products
36
+ from malloryapi.resources.references import (
37
+ AsyncReferences,
38
+ References,
39
+ )
40
+ from malloryapi.resources.search import AsyncSearch, Search
41
+ from malloryapi.resources.sources import AsyncSources, Sources
42
+ from malloryapi.resources.stories import AsyncStories, Stories
43
+ from malloryapi.resources.technology_product_advisories import (
44
+ AsyncTechnologyProductAdvisories,
45
+ TechnologyProductAdvisories,
46
+ )
47
+ from malloryapi.resources.threat_actors import (
48
+ AsyncThreatActors,
49
+ ThreatActors,
50
+ )
51
+ from malloryapi.resources.vulnerabilities import (
52
+ AsyncVulnerabilities,
53
+ Vulnerabilities,
54
+ )
55
+ from malloryapi.resources.weaknesses import (
56
+ AsyncWeaknesses,
57
+ Weaknesses,
58
+ )
59
+
60
+
61
+ class MalloryApi:
62
+ """Synchronous client for the Mallory threat intelligence API.
63
+
64
+ Usage::
65
+
66
+ from malloryapi import MalloryApi
67
+
68
+ client = MalloryApi(api_key="sk-...")
69
+ vulns = client.vulnerabilities.list(limit=10)
70
+ actor = client.threat_actors.get("apt28-uuid")
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ api_key: str | None = None,
76
+ base_url: str = DEFAULT_BASE_URL,
77
+ timeout: float = DEFAULT_TIMEOUT,
78
+ ) -> None:
79
+ self._http = SyncHttpClient(
80
+ api_key=api_key, base_url=base_url, timeout=timeout
81
+ )
82
+
83
+ # Entities
84
+ self.vulnerabilities = Vulnerabilities(self._http)
85
+ self.threat_actors = ThreatActors(self._http)
86
+ self.malware = Malware(self._http)
87
+ self.exploits = Exploits(self._http)
88
+ self.exploitations = Exploitations(self._http)
89
+ self.organizations = Organizations(self._http)
90
+ self.products = Products(self._http)
91
+ self.attack_patterns = AttackPatterns(self._http)
92
+ self.breaches = Breaches(self._http)
93
+ self.detection_signatures = DetectionSignatures(self._http)
94
+ self.advisories = TechnologyProductAdvisories(self._http)
95
+ self.weaknesses = Weaknesses(self._http)
96
+
97
+ # Content
98
+ self.stories = Stories(self._http)
99
+ self.references = References(self._http)
100
+ self.sources = Sources(self._http)
101
+ self.content_chunks = ContentChunks(self._http)
102
+
103
+ # Analytics
104
+ self.mentions = Mentions(self._http)
105
+ self.search = Search(self._http)
106
+
107
+ def close(self) -> None:
108
+ """Close the underlying HTTP connection."""
109
+ self._http.close()
110
+
111
+ def __enter__(self) -> MalloryApi:
112
+ return self
113
+
114
+ def __exit__(self, *args) -> None:
115
+ self.close()
116
+
117
+
118
+ class AsyncMalloryApi:
119
+ """Asynchronous client for the Mallory threat intelligence API.
120
+
121
+ Usage::
122
+
123
+ from malloryapi import AsyncMalloryApi
124
+
125
+ async with AsyncMalloryApi(api_key="sk-...") as client:
126
+ vulns = await client.vulnerabilities.list(limit=10)
127
+ actor = await client.threat_actors.get("apt28-uuid")
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ api_key: str | None = None,
133
+ base_url: str = DEFAULT_BASE_URL,
134
+ timeout: float = DEFAULT_TIMEOUT,
135
+ ) -> None:
136
+ self._http = AsyncHttpClient(
137
+ api_key=api_key, base_url=base_url, timeout=timeout
138
+ )
139
+
140
+ # Entities
141
+ self.vulnerabilities = AsyncVulnerabilities(self._http)
142
+ self.threat_actors = AsyncThreatActors(self._http)
143
+ self.malware = AsyncMalware(self._http)
144
+ self.exploits = AsyncExploits(self._http)
145
+ self.exploitations = AsyncExploitations(self._http)
146
+ self.organizations = AsyncOrganizations(self._http)
147
+ self.products = AsyncProducts(self._http)
148
+ self.attack_patterns = AsyncAttackPatterns(self._http)
149
+ self.breaches = AsyncBreaches(self._http)
150
+ self.detection_signatures = AsyncDetectionSignatures(
151
+ self._http
152
+ )
153
+ self.advisories = AsyncTechnologyProductAdvisories(
154
+ self._http
155
+ )
156
+ self.weaknesses = AsyncWeaknesses(self._http)
157
+
158
+ # Content
159
+ self.stories = AsyncStories(self._http)
160
+ self.references = AsyncReferences(self._http)
161
+ self.sources = AsyncSources(self._http)
162
+ self.content_chunks = AsyncContentChunks(self._http)
163
+
164
+ # Analytics
165
+ self.mentions = AsyncMentions(self._http)
166
+ self.search = AsyncSearch(self._http)
167
+
168
+ async def aclose(self) -> None:
169
+ """Close the underlying HTTP connection."""
170
+ await self._http.aclose()
171
+
172
+ async def __aenter__(self) -> AsyncMalloryApi:
173
+ return self
174
+
175
+ async def __aexit__(self, *args) -> None:
176
+ await self.aclose()
@@ -0,0 +1,35 @@
1
+ """Exceptions raised by the Mallory API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class APIError(Exception):
9
+ """Base exception for all Mallory API errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ status_code: int | None = None,
15
+ response_body: Any = None,
16
+ ) -> None:
17
+ self.status_code = status_code
18
+ self.response_body = response_body
19
+ super().__init__(message)
20
+
21
+
22
+ class AuthenticationError(APIError):
23
+ """Raised on 401 or 403 responses."""
24
+
25
+
26
+ class NotFoundError(APIError):
27
+ """Raised on 404 responses."""
28
+
29
+
30
+ class ValidationError(APIError):
31
+ """Raised on 422 responses."""
32
+
33
+
34
+ class RateLimitError(APIError):
35
+ """Raised on 429 responses."""
File without changes
@@ -0,0 +1,133 @@
1
+ """Base resource classes for sync and async API access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from malloryapi._http import AsyncHttpClient, SyncHttpClient
8
+ from malloryapi._types import PaginatedResponse
9
+
10
+ TrendingPeriod = Literal["1d", "7d", "30d"]
11
+
12
+
13
+ class SyncResource:
14
+ """Base class for synchronous resource clients."""
15
+
16
+ _path: str # e.g. "/vulnerabilities"
17
+
18
+ def __init__(self, http: SyncHttpClient) -> None:
19
+ self._http = http
20
+
21
+ # -- common helpers ------------------------------------------------
22
+
23
+ def _list(
24
+ self,
25
+ params: dict[str, Any] | None = None,
26
+ **extra: Any,
27
+ ) -> PaginatedResponse:
28
+ merged = {**(params or {}), **extra}
29
+ merged = {k: v for k, v in merged.items() if v is not None}
30
+ data = self._http.get(self._path, params=merged)
31
+ return _parse_paginated(data)
32
+
33
+ def _get(self, identifier: str) -> dict[str, Any]:
34
+ return self._http.get(f"{self._path}/{identifier}")
35
+
36
+ def _sub(
37
+ self,
38
+ identifier: str,
39
+ sub: str,
40
+ params: dict[str, Any] | None = None,
41
+ ) -> Any:
42
+ return self._http.get(
43
+ f"{self._path}/{identifier}/{sub}", params=params
44
+ )
45
+
46
+ def _post(
47
+ self,
48
+ path: str | None = None,
49
+ json: Any = None,
50
+ params: dict[str, Any] | None = None,
51
+ ) -> Any:
52
+ return self._http.post(
53
+ path or self._path, json=json, params=params
54
+ )
55
+
56
+ def _patch(
57
+ self,
58
+ identifier: str,
59
+ json: Any = None,
60
+ ) -> Any:
61
+ return self._http.patch(
62
+ f"{self._path}/{identifier}", json=json
63
+ )
64
+
65
+
66
+ class AsyncResource:
67
+ """Base class for asynchronous resource clients."""
68
+
69
+ _path: str
70
+
71
+ def __init__(self, http: AsyncHttpClient) -> None:
72
+ self._http = http
73
+
74
+ async def _list(
75
+ self,
76
+ params: dict[str, Any] | None = None,
77
+ **extra: Any,
78
+ ) -> PaginatedResponse:
79
+ merged = {**(params or {}), **extra}
80
+ merged = {k: v for k, v in merged.items() if v is not None}
81
+ data = await self._http.get(self._path, params=merged)
82
+ return _parse_paginated(data)
83
+
84
+ async def _get(self, identifier: str) -> dict[str, Any]:
85
+ return await self._http.get(f"{self._path}/{identifier}")
86
+
87
+ async def _sub(
88
+ self,
89
+ identifier: str,
90
+ sub: str,
91
+ params: dict[str, Any] | None = None,
92
+ ) -> Any:
93
+ return await self._http.get(
94
+ f"{self._path}/{identifier}/{sub}", params=params
95
+ )
96
+
97
+ async def _post(
98
+ self,
99
+ path: str | None = None,
100
+ json: Any = None,
101
+ params: dict[str, Any] | None = None,
102
+ ) -> Any:
103
+ return await self._http.post(
104
+ path or self._path, json=json, params=params
105
+ )
106
+
107
+ async def _patch(
108
+ self,
109
+ identifier: str,
110
+ json: Any = None,
111
+ ) -> Any:
112
+ return await self._http.patch(
113
+ f"{self._path}/{identifier}", json=json
114
+ )
115
+
116
+
117
+ # -- helpers -----------------------------------------------------------
118
+
119
+
120
+ def _parse_paginated(data: Any) -> PaginatedResponse:
121
+ """Parse a paginated API response into a PaginatedResponse."""
122
+ if isinstance(data, dict):
123
+ return PaginatedResponse(
124
+ items=data.get("items", data.get("data", [])),
125
+ total=data.get("total", 0),
126
+ offset=data.get("offset", 0),
127
+ limit=data.get("limit", 100),
128
+ )
129
+ if isinstance(data, list):
130
+ return PaginatedResponse(
131
+ items=data, total=len(data), offset=0, limit=len(data)
132
+ )
133
+ return PaginatedResponse()