paravane 0.2.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.
paravane/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Python SDK for the Paravane API."""
2
+
3
+ from paravane._version import __version__
4
+ from paravane.client import ParavaneClient
5
+ from paravane.errors import (
6
+ APIConnectionError,
7
+ APIError,
8
+ AuthenticationError,
9
+ ConfigurationError,
10
+ ParavaneError,
11
+ PermissionDeniedError,
12
+ QuotaExceededError,
13
+ RateLimitError,
14
+ ValidationError,
15
+ )
16
+ from paravane.types import (
17
+ AnalysisProfile,
18
+ CompanyValidityBeta,
19
+ DomainSignal,
20
+ SmtpRsAnalysis,
21
+ )
22
+
23
+ __all__ = [
24
+ "APIConnectionError",
25
+ "APIError",
26
+ "AnalysisProfile",
27
+ "AuthenticationError",
28
+ "ConfigurationError",
29
+ "CompanyValidityBeta",
30
+ "DomainSignal",
31
+ "ParavaneClient",
32
+ "ParavaneError",
33
+ "PermissionDeniedError",
34
+ "QuotaExceededError",
35
+ "RateLimitError",
36
+ "SmtpRsAnalysis",
37
+ "ValidationError",
38
+ "__version__",
39
+ ]
paravane/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
paravane/client.py ADDED
@@ -0,0 +1,49 @@
1
+ """Client entrypoint for the Paravane SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ import requests
9
+
10
+ from paravane.errors import ConfigurationError
11
+ from paravane.http import HTTPClient
12
+ from paravane.smtprs import SmtpRsResource
13
+
14
+
15
+ class ParavaneClient:
16
+ """Client for Paravane APIs.
17
+
18
+ Parameters can be supplied directly or through environment variables:
19
+ ``PARAVANE_API_KEY`` and ``PARAVANE_BASE_URL``.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ api_key: Optional[str] = None,
25
+ *,
26
+ base_url: Optional[str] = None,
27
+ timeout: float = 15.0,
28
+ max_network_retries: int = 0,
29
+ session: Optional[requests.Session] = None,
30
+ ) -> None:
31
+ resolved_api_key = api_key or os.getenv("PARAVANE_API_KEY")
32
+ if not resolved_api_key:
33
+ raise ConfigurationError(
34
+ "Missing Paravane API key. Pass api_key=... or set PARAVANE_API_KEY."
35
+ )
36
+
37
+ resolved_base_url = base_url or os.getenv("PARAVANE_BASE_URL") or "https://api.paravane.io"
38
+ self.api_key = resolved_api_key
39
+ self.base_url = resolved_base_url.rstrip("/")
40
+ self.timeout = timeout
41
+ self.max_network_retries = max_network_retries
42
+ self._http = HTTPClient(
43
+ api_key=self.api_key,
44
+ base_url=self.base_url,
45
+ timeout=self.timeout,
46
+ max_network_retries=max_network_retries,
47
+ session=session,
48
+ )
49
+ self.smtprs = SmtpRsResource(self._http)
paravane/errors.py ADDED
@@ -0,0 +1,67 @@
1
+ """Exception hierarchy for the Paravane SDK."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class ParavaneError(Exception):
7
+ """Base class for all SDK errors."""
8
+
9
+
10
+ class ConfigurationError(ParavaneError):
11
+ """Raised when the client is missing required configuration."""
12
+
13
+
14
+ class APIConnectionError(ParavaneError):
15
+ """Raised when the SDK cannot reach the API."""
16
+
17
+
18
+ class APIError(ParavaneError):
19
+ """Raised when the API returns an unsuccessful response."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ status_code: Optional[int] = None,
26
+ code: Optional[str] = None,
27
+ response: Optional[Dict[str, Any]] = None,
28
+ request_id: Optional[str] = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.status_code = status_code
33
+ self.code = code
34
+ self.response = response
35
+ self.request_id = request_id
36
+
37
+ def __str__(self) -> str:
38
+ details = []
39
+ if self.status_code:
40
+ details.append(f"status_code={self.status_code}")
41
+ if self.code:
42
+ details.append(f"code={self.code}")
43
+ if self.request_id:
44
+ details.append(f"request_id={self.request_id}")
45
+ if not details:
46
+ return self.message
47
+ return f"{self.message} ({', '.join(details)})"
48
+
49
+
50
+ class AuthenticationError(APIError):
51
+ """Raised when an API key is missing or invalid."""
52
+
53
+
54
+ class PermissionDeniedError(APIError):
55
+ """Raised when the API key does not have access to a resource."""
56
+
57
+
58
+ class ValidationError(APIError):
59
+ """Raised when request validation fails."""
60
+
61
+
62
+ class QuotaExceededError(APIError):
63
+ """Raised when a workspace has exhausted its plan quota."""
64
+
65
+
66
+ class RateLimitError(APIError):
67
+ """Raised when the API rate limit is exceeded."""
paravane/http.py ADDED
@@ -0,0 +1,166 @@
1
+ """Low-level HTTP transport for Paravane API requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any, Dict, Mapping, Optional
8
+
9
+ import requests
10
+
11
+ from paravane._version import __version__
12
+ from paravane.errors import (
13
+ APIConnectionError,
14
+ APIError,
15
+ AuthenticationError,
16
+ PermissionDeniedError,
17
+ QuotaExceededError,
18
+ RateLimitError,
19
+ ValidationError,
20
+ )
21
+
22
+
23
+ class HTTPClient:
24
+ """Small requests-based HTTP client used by resource classes."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ api_key: str,
30
+ base_url: str,
31
+ timeout: float,
32
+ max_network_retries: int,
33
+ session: Optional[requests.Session] = None,
34
+ ) -> None:
35
+ self.api_key = api_key
36
+ self.base_url = base_url.rstrip("/")
37
+ self.timeout = timeout
38
+ self.max_network_retries = max(0, int(max_network_retries))
39
+ self.session = session or requests.Session()
40
+
41
+ def request(
42
+ self,
43
+ method: str,
44
+ path: str,
45
+ *,
46
+ json_body: Optional[Mapping[str, Any]] = None,
47
+ params: Optional[Mapping[str, Any]] = None,
48
+ headers: Optional[Mapping[str, str]] = None,
49
+ timeout: Optional[float] = None,
50
+ idempotency_key: Optional[str] = None,
51
+ ) -> Dict[str, Any]:
52
+ url = f"{self.base_url}{path}"
53
+ request_headers = {
54
+ "Accept": "application/json",
55
+ "Content-Type": "application/json",
56
+ "User-Agent": f"paravane-python/{__version__}",
57
+ "X-API-Key": self.api_key,
58
+ }
59
+ if idempotency_key:
60
+ request_headers["Idempotency-Key"] = idempotency_key
61
+ if headers:
62
+ request_headers.update(headers)
63
+
64
+ attempt = 0
65
+ while True:
66
+ try:
67
+ response = self.session.request(
68
+ method,
69
+ url,
70
+ json=dict(json_body) if json_body is not None else None,
71
+ params=dict(params) if params is not None else None,
72
+ headers=request_headers,
73
+ timeout=self.timeout if timeout is None else timeout,
74
+ )
75
+ except requests.RequestException as exc:
76
+ if attempt >= self.max_network_retries:
77
+ raise APIConnectionError(f"Could not connect to Paravane API: {exc}") from exc
78
+ self._sleep_before_retry(attempt)
79
+ attempt += 1
80
+ continue
81
+
82
+ if response.status_code < 400:
83
+ return self._decode_response(response)
84
+
85
+ error = self._build_api_error(response)
86
+ if self._should_retry(response.status_code, attempt):
87
+ self._sleep_before_retry(attempt)
88
+ attempt += 1
89
+ continue
90
+ raise error
91
+
92
+ def _decode_response(self, response: requests.Response) -> Dict[str, Any]:
93
+ if not response.content:
94
+ return {}
95
+ try:
96
+ payload = response.json()
97
+ except ValueError as exc:
98
+ raise APIError(
99
+ "Paravane API returned a non-JSON response.",
100
+ status_code=response.status_code,
101
+ request_id=response.headers.get("X-Request-Id"),
102
+ ) from exc
103
+ if not isinstance(payload, dict):
104
+ return {"data": payload}
105
+ return payload
106
+
107
+ def _build_api_error(self, response: requests.Response) -> APIError:
108
+ request_id = response.headers.get("X-Request-Id")
109
+ payload: Dict[str, Any]
110
+ try:
111
+ decoded = response.json()
112
+ payload = decoded if isinstance(decoded, dict) else {"error": decoded}
113
+ except (ValueError, json.JSONDecodeError):
114
+ payload = {"error": response.text}
115
+
116
+ code = self._extract_code(payload)
117
+ message = self._extract_message(payload)
118
+ error_cls = self._error_class(response.status_code, code, message)
119
+ return error_cls(
120
+ message,
121
+ status_code=response.status_code,
122
+ code=code,
123
+ response=payload,
124
+ request_id=request_id,
125
+ )
126
+
127
+ def _extract_code(self, payload: Mapping[str, Any]) -> Optional[str]:
128
+ error = payload.get("error")
129
+ if isinstance(error, dict):
130
+ code = error.get("code") or error.get("type")
131
+ return str(code) if code else None
132
+ code = payload.get("code")
133
+ return str(code) if code else None
134
+
135
+ def _extract_message(self, payload: Mapping[str, Any]) -> str:
136
+ error = payload.get("error")
137
+ if isinstance(error, dict):
138
+ message = error.get("message") or error.get("detail")
139
+ if message:
140
+ return str(message)
141
+ if isinstance(error, str) and error:
142
+ return error
143
+ for key in ("message", "detail"):
144
+ if payload.get(key):
145
+ return str(payload[key])
146
+ return "Paravane API request failed."
147
+
148
+ def _error_class(self, status_code: int, code: Optional[str], message: str):
149
+ folded = f"{code or ''} {message}".lower()
150
+ if status_code == 400 or status_code == 422:
151
+ return ValidationError
152
+ if status_code == 401:
153
+ return AuthenticationError
154
+ if status_code == 403:
155
+ return PermissionDeniedError
156
+ if status_code == 402 or "quota" in folded or "credit_limit" in folded:
157
+ return QuotaExceededError
158
+ if status_code == 429:
159
+ return RateLimitError
160
+ return APIError
161
+
162
+ def _should_retry(self, status_code: int, attempt: int) -> bool:
163
+ return attempt < self.max_network_retries and (status_code == 429 or status_code >= 500)
164
+
165
+ def _sleep_before_retry(self, attempt: int) -> None:
166
+ time.sleep(min(0.5 * (2**attempt), 2.0))
paravane/py.typed ADDED
@@ -0,0 +1 @@
1
+
paravane/smtprs.py ADDED
@@ -0,0 +1,68 @@
1
+ """smtpRS resource helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Mapping, Optional
6
+
7
+ from paravane.http import HTTPClient
8
+ from paravane.types import AnalysisProfile, SmtpRsAnalysis
9
+
10
+
11
+ class SmtpRsResource:
12
+ """Access smtpRS email risk intelligence endpoints."""
13
+
14
+ def __init__(self, http_client: HTTPClient) -> None:
15
+ self._http = http_client
16
+
17
+ def analyze(
18
+ self,
19
+ email: str,
20
+ *,
21
+ profile: Optional[AnalysisProfile] = None,
22
+ disposable_only: Optional[bool] = None,
23
+ strict_disposable: Optional[bool] = None,
24
+ guess: Optional[bool] = None,
25
+ run_catch_all: Optional[bool] = None,
26
+ company_validity_beta: Optional[bool] = None,
27
+ fast: Optional[bool] = None,
28
+ extra_params: Optional[Mapping[str, Any]] = None,
29
+ tenant_id: Optional[str] = None,
30
+ timeout: Optional[float] = None,
31
+ idempotency_key: Optional[str] = None,
32
+ ) -> SmtpRsAnalysis:
33
+ """Analyze one email address with smtpRS.
34
+
35
+ ``profile`` selects a complete public analysis mode. The older Boolean
36
+ mode flags remain available for compatibility but are deprecated by the
37
+ API. ``company_validity_beta`` opts an eligible paid request into the
38
+ optional company-domain context.
39
+ Unknown future parameters can be supplied with ``extra_params``.
40
+ """
41
+
42
+ params: Dict[str, Any] = {
43
+ "profile": profile,
44
+ "disposable_only": disposable_only,
45
+ "strict_disposable": strict_disposable,
46
+ "guess": guess,
47
+ "run_catch_all": run_catch_all,
48
+ # Keep this absent unless explicitly set so existing callers retain
49
+ # the server's default, scoring-neutral behavior.
50
+ "company_validity_beta": company_validity_beta,
51
+ "fast": fast,
52
+ }
53
+ if extra_params:
54
+ params.update(extra_params)
55
+ clean_params = {key: value for key, value in params.items() if value is not None}
56
+ body: Dict[str, Any] = {"email": email}
57
+ if tenant_id is not None:
58
+ body["tenant_id"] = tenant_id
59
+
60
+ payload = self._http.request(
61
+ "POST",
62
+ "/v1/analyse",
63
+ json_body=body,
64
+ params=clean_params,
65
+ timeout=timeout,
66
+ idempotency_key=idempotency_key,
67
+ )
68
+ return SmtpRsAnalysis.from_dict(payload)
paravane/types.py ADDED
@@ -0,0 +1,172 @@
1
+ """Typed response helpers returned by the SDK."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Literal, Mapping, Optional
5
+
6
+ AnalysisProfile = Literal["quick", "standard", "adaptive", "deep", "catch_all"]
7
+
8
+
9
+ def _optional_bool(value: Any) -> Optional[bool]:
10
+ return value if isinstance(value, bool) else None
11
+
12
+
13
+ def _first_present(data: Mapping[str, Any], *keys: str) -> Any:
14
+ for key in keys:
15
+ if key in data:
16
+ return data[key]
17
+ return None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CompanyValidityBeta:
22
+ """Beta metadata returned when company-validity support is available."""
23
+
24
+ feature: Optional[str] = None
25
+ status: Optional[str] = None
26
+ requested: bool = False
27
+ enabled: bool = False
28
+ availability: Optional[str] = None
29
+ additional_credit_cost: Optional[int] = None
30
+ notes: List[str] = field(default_factory=list)
31
+ raw: Dict[str, Any] = field(default_factory=dict)
32
+
33
+ @classmethod
34
+ def from_dict(cls, payload: Mapping[str, Any]) -> "CompanyValidityBeta":
35
+ data = dict(payload)
36
+ notes = data.get("notes") if isinstance(data.get("notes"), list) else []
37
+ return cls(
38
+ feature=data.get("feature"),
39
+ status=data.get("status"),
40
+ requested=bool(data.get("requested")),
41
+ enabled=bool(data.get("enabled")),
42
+ availability=data.get("availability"),
43
+ additional_credit_cost=data.get("additional_credit_cost"),
44
+ notes=[str(note) for note in notes],
45
+ raw=data,
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DomainSignal:
51
+ """Normalized public facts about an email domain and its mail posture."""
52
+
53
+ contract_version: Optional[str] = None
54
+ domain: Optional[str] = None
55
+ registrable_domain: Optional[str] = None
56
+ domain_status: Optional[str] = None
57
+ domain_exists: Optional[bool] = None
58
+ mail_status: Optional[str] = None
59
+ mail_capable: Optional[bool] = None
60
+ mail_usage: Optional[str] = None
61
+ spf_present: Optional[bool] = None
62
+ dmarc_present: Optional[bool] = None
63
+ organization_status: Optional[str] = None
64
+ organization_backed: Optional[bool] = None
65
+ organization_known: Optional[bool] = None
66
+ organization_verified: Optional[bool] = None
67
+ organization_confidence: Optional[str] = None
68
+ company_valid: Optional[bool] = None
69
+ company_confidence: Optional[str] = None
70
+ company_checked_at: Optional[str] = None
71
+ evaluated_at: Optional[str] = None
72
+ reason_codes: List[str] = field(default_factory=list)
73
+ raw: Dict[str, Any] = field(default_factory=dict)
74
+
75
+ @classmethod
76
+ def from_dict(cls, payload: Mapping[str, Any]) -> "DomainSignal":
77
+ data = dict(payload)
78
+ reason_codes = (
79
+ data.get("reason_codes") if isinstance(data.get("reason_codes"), list) else []
80
+ )
81
+ return cls(
82
+ contract_version=data.get("contract_version"),
83
+ domain=data.get("domain"),
84
+ registrable_domain=data.get("registrable_domain"),
85
+ domain_status=data.get("domain_status"),
86
+ domain_exists=_optional_bool(data.get("domain_exists")),
87
+ mail_status=data.get("mail_status"),
88
+ mail_capable=_optional_bool(data.get("mail_capable")),
89
+ mail_usage=data.get("mail_usage"),
90
+ spf_present=_optional_bool(data.get("spf_present")),
91
+ dmarc_present=_optional_bool(data.get("dmarc_present")),
92
+ organization_status=data.get("organization_status"),
93
+ organization_backed=_optional_bool(data.get("organization_backed")),
94
+ organization_known=_optional_bool(data.get("organization_known")),
95
+ organization_verified=_optional_bool(data.get("organization_verified")),
96
+ organization_confidence=data.get("organization_confidence"),
97
+ company_valid=_optional_bool(data.get("company_valid")),
98
+ company_confidence=data.get("company_confidence"),
99
+ company_checked_at=data.get("company_checked_at"),
100
+ evaluated_at=data.get("evaluated_at"),
101
+ reason_codes=[str(code) for code in reason_codes],
102
+ raw=data,
103
+ )
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class SmtpRsAnalysis:
108
+ """A normalized smtpRS analyse response with the raw payload preserved."""
109
+
110
+ email: Optional[str] = None
111
+ decision: Optional[str] = None
112
+ overall_risk: Optional[float] = None
113
+ tier: Optional[str] = None
114
+ analysis_profile: Optional[str] = None
115
+ response_profile: Optional[str] = None
116
+ credit_cost: Optional[int] = None
117
+ credits_charged: Optional[int] = None
118
+ reasons: List[str] = field(default_factory=list)
119
+ usage: Dict[str, Any] = field(default_factory=dict)
120
+ raw: Dict[str, Any] = field(default_factory=dict)
121
+ # Appended to preserve the positional field order of the 0.1.0 dataclass.
122
+ company_validity_beta: Optional[CompanyValidityBeta] = None
123
+ raw_risk: Optional[float] = None
124
+ raw_decision: Optional[str] = None
125
+ organization_validity_enabled: Optional[bool] = None
126
+ organization_adjustment: Optional[float] = None
127
+ domain_signal: Optional[DomainSignal] = None
128
+
129
+ @classmethod
130
+ def from_dict(cls, payload: Mapping[str, Any]) -> "SmtpRsAnalysis":
131
+ data = dict(payload)
132
+ usage = data.get("usage") if isinstance(data.get("usage"), dict) else {}
133
+ reasons = data.get("reasons") if isinstance(data.get("reasons"), list) else []
134
+ company_validity_data = data.get("company_validity_beta")
135
+ company_validity_beta = (
136
+ CompanyValidityBeta.from_dict(company_validity_data)
137
+ if isinstance(company_validity_data, Mapping)
138
+ else None
139
+ )
140
+ domain_signal_data = data.get("domain_signal")
141
+ domain_signal = (
142
+ DomainSignal.from_dict(domain_signal_data)
143
+ if isinstance(domain_signal_data, Mapping)
144
+ else None
145
+ )
146
+
147
+ return cls(
148
+ email=data.get("email") or data.get("input"),
149
+ decision=data.get("decision"),
150
+ overall_risk=_first_present(data, "overall_risk", "risk_score"),
151
+ tier=data.get("tier"),
152
+ analysis_profile=data.get("analysis_profile"),
153
+ response_profile=data.get("response_profile"),
154
+ credit_cost=data.get("credit_cost"),
155
+ credits_charged=data.get("credits_charged"),
156
+ company_validity_beta=company_validity_beta,
157
+ raw_risk=data.get("raw_risk"),
158
+ raw_decision=data.get("raw_decision"),
159
+ organization_validity_enabled=_optional_bool(
160
+ data.get("organization_validity_enabled")
161
+ ),
162
+ organization_adjustment=data.get("organization_adjustment"),
163
+ domain_signal=domain_signal,
164
+ reasons=[str(reason) for reason in reasons],
165
+ usage=dict(usage),
166
+ raw=data,
167
+ )
168
+
169
+ def to_dict(self) -> Dict[str, Any]:
170
+ """Return the original API response payload."""
171
+
172
+ return dict(self.raw)
@@ -0,0 +1,697 @@
1
+ Metadata-Version: 2.4
2
+ Name: paravane
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the Paravane API.
5
+ Author-email: Paravane Labs <contact@paravane.io>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Paravane Labs
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://paravane.io
29
+ Project-URL: Documentation, https://paravane.io/pages/docs/index.html
30
+ Project-URL: Changelog, https://github.com/paravaneai/paravane-python/blob/main/CHANGELOG.md
31
+ Project-URL: Source, https://github.com/paravaneai/paravane-python
32
+ Project-URL: Issues, https://github.com/paravaneai/paravane-python/issues
33
+ Keywords: paravane,smtprs,email risk,api,sdk
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3 :: Only
40
+ Classifier: Programming Language :: Python :: 3.8
41
+ Classifier: Programming Language :: Python :: 3.9
42
+ Classifier: Programming Language :: Python :: 3.10
43
+ Classifier: Programming Language :: Python :: 3.11
44
+ Classifier: Programming Language :: Python :: 3.12
45
+ Classifier: Programming Language :: Python :: 3.13
46
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
47
+ Requires-Python: >=3.8
48
+ Description-Content-Type: text/markdown
49
+ License-File: LICENSE
50
+ Requires-Dist: requests>=2.31.0
51
+ Provides-Extra: dev
52
+ Requires-Dist: build>=1.2.1; extra == "dev"
53
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
54
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
55
+ Requires-Dist: setuptools>=61.0; extra == "dev"
56
+ Requires-Dist: tomli>=2.0.0; python_version < "3.11" and extra == "dev"
57
+ Requires-Dist: twine>=5.1.0; extra == "dev"
58
+ Requires-Dist: wheel; extra == "dev"
59
+ Dynamic: license-file
60
+
61
+ <p align="center">
62
+ <picture>
63
+ <source
64
+ media="(prefers-color-scheme: light)"
65
+ srcset="https://raw.githubusercontent.com/paravaneai/paravane-python/main/doc/images/readme/paravane-smtprs-sdk-readme-banner-logo-matched-orange-dark-developer-api-upscaled-3x.png" />
66
+ <img
67
+ src="https://raw.githubusercontent.com/paravaneai/paravane-python/main/doc/images/readme/paravane-smtprs-sdk-readme-banner-logo-matched-orange-dark-developer-api-upscaled-3x.png"
68
+ alt="Paravane" />
69
+ </picture>
70
+ </p>
71
+
72
+ <h1 align="center">
73
+ Paravane Python Library
74
+ </h1>
75
+
76
+ <p align="center">
77
+ The Paravane Python library provides convenient access to the Paravane API from applications written in Python.
78
+ <br /><br />
79
+ The first supported product API is <strong>smtpRS</strong>, Paravane's email risk intelligence API.
80
+ smtpRS helps score email addresses for onboarding, trust, review, abuse-prevention, and operations workflows.
81
+ <br /><br />
82
+ This SDK is intentionally small at the start. It provides a stable client entrypoint,
83
+ request serialization, response helpers, structured errors, examples, tests, and packaging
84
+ conventions that can grow as Paravane adds more APIs.
85
+ </p>
86
+
87
+ <p align="center">
88
+ <a href="https://pypi.org/project/paravane/">
89
+ <img
90
+ src="https://img.shields.io/pypi/v/paravane.svg"
91
+ alt="PyPI" />
92
+ </a>
93
+ <a href="https://pypi.org/project/paravane/">
94
+ <img
95
+ src="https://img.shields.io/pypi/pyversions/paravane.svg"
96
+ alt="Supported Python versions" />
97
+ </a>
98
+ <a href="https://github.com/paravaneai/paravane-python/actions/workflows/tests.yml">
99
+ <img
100
+ src="https://github.com/paravaneai/paravane-python/actions/workflows/tests.yml/badge.svg"
101
+ alt="Tests" />
102
+ </a>
103
+ </p>
104
+
105
+ <h3 align="center">
106
+ <a href="#documentation">Documentation</a>
107
+ <span> · </span>
108
+ <a href="#installation">Installation</a>
109
+ <span> · </span>
110
+ <a href="#quickstart">Quickstart</a>
111
+ <span> · </span>
112
+ <a href="#examples">Examples</a>
113
+ <span> · </span>
114
+ <a href="#development">Development</a>
115
+ </h3>
116
+
117
+ ## Contents
118
+
119
+ - [Documentation](#documentation)
120
+ - [Installation](#installation)
121
+ - [Requirements](#requirements)
122
+ - [Quickstart](#quickstart)
123
+ - [Usage](#usage)
124
+ - [smtpRS](#smtprs)
125
+ - [Configuration](#configuration)
126
+ - [Per-request options](#per-request-options)
127
+ - [Custom HTTP sessions](#custom-http-sessions)
128
+ - [Retries](#retries)
129
+ - [Idempotency](#idempotency)
130
+ - [Errors](#errors)
131
+ - [Responses and raw data](#responses-and-raw-data)
132
+ - [Types](#types)
133
+ - [Logging](#logging)
134
+ - [Examples](#examples)
135
+ - [Development](#development)
136
+ - [Repository layout](#repository-layout)
137
+ - [Versioning](#versioning)
138
+ - [Security](#security)
139
+ - [Support](#support)
140
+
141
+ ## Documentation
142
+
143
+ - Product docs: https://paravane.io/pages/docs/index.html
144
+ - smtpRS docs: https://paravane.io/pages/docs/smtprs/index.html
145
+ - Analyse endpoint: https://paravane.io/pages/docs/smtprs/analyse.html
146
+ - Plans and limits: https://paravane.io/pages/docs/smtprs/plans-limits.html
147
+
148
+ ## Installation
149
+
150
+ Install the latest release from PyPI:
151
+
152
+ ```bash
153
+ python -m pip install --upgrade paravane
154
+ ```
155
+
156
+ To install the current development version directly from GitHub:
157
+
158
+ ```bash
159
+ python -m pip install "git+https://github.com/paravaneai/paravane-python.git"
160
+ ```
161
+
162
+ For local development:
163
+
164
+ ```bash
165
+ git clone git@github.com:paravaneai/paravane-python.git
166
+ cd paravane-python
167
+ python -m pip install -e ".[dev]"
168
+ ```
169
+
170
+ ## Requirements
171
+
172
+ Python 3.8 or newer.
173
+
174
+ Runtime dependency:
175
+
176
+ - `requests>=2.31.0`
177
+
178
+ Development dependencies are installed with:
179
+
180
+ ```bash
181
+ python -m pip install -e ".[dev]"
182
+ ```
183
+
184
+ ## Quickstart
185
+
186
+ Set your API key:
187
+
188
+ ```bash
189
+ export PARAVANE_API_KEY="pvn_live_..."
190
+ ```
191
+
192
+ On Windows PowerShell:
193
+
194
+ ```powershell
195
+ $env:PARAVANE_API_KEY = "pvn_live_..."
196
+ ```
197
+
198
+ Call smtpRS:
199
+
200
+ ```python
201
+ from paravane import ParavaneClient
202
+
203
+ client = ParavaneClient()
204
+
205
+ result = client.smtprs.analyze("alice@example.com")
206
+
207
+ print(result.decision)
208
+ print(result.overall_risk)
209
+ print(result.credits_charged)
210
+ ```
211
+
212
+ The SDK method is named `analyze(...)` for Python readability. The HTTP API endpoint remains:
213
+
214
+ ```text
215
+ POST /v1/analyse
216
+ ```
217
+
218
+ ## Usage
219
+
220
+ Create a client with an API key:
221
+
222
+ ```python
223
+ from paravane import ParavaneClient
224
+
225
+ client = ParavaneClient(api_key="pvn_live_...")
226
+ ```
227
+
228
+ Or use `PARAVANE_API_KEY`:
229
+
230
+ ```python
231
+ from paravane import ParavaneClient
232
+
233
+ client = ParavaneClient()
234
+ ```
235
+
236
+ Run an email risk analysis:
237
+
238
+ ```python
239
+ result = client.smtprs.analyze("person@example.com")
240
+
241
+ if result.decision == "allow":
242
+ print("Continue")
243
+ else:
244
+ print("Review or block")
245
+ ```
246
+
247
+ Inspect the normalized response:
248
+
249
+ ```python
250
+ print(result.email)
251
+ print(result.decision)
252
+ print(result.overall_risk)
253
+ print(result.analysis_profile)
254
+ print(result.credit_cost)
255
+ print(result.usage)
256
+ ```
257
+
258
+ Access the original API response:
259
+
260
+ ```python
261
+ raw = result.to_dict()
262
+ print(raw)
263
+ ```
264
+
265
+ ## smtpRS
266
+
267
+ smtpRS is available through:
268
+
269
+ ```python
270
+ client.smtprs
271
+ ```
272
+
273
+ ### Analysis profiles
274
+
275
+ smtpRS provides named profiles so applications can choose the appropriate balance of
276
+ coverage, latency, and credit usage without configuring individual checks.
277
+
278
+ | Profile | Credits | Availability | Intended use |
279
+ | --- | ---: | --- | --- |
280
+ | `quick` | 1 | All plans | Lightweight screening; this is the default. |
281
+ | `standard` | 3 | Basic and above | Broader passive analysis. |
282
+ | `adaptive` | 5 | Pro and above | Adds checks when the initial result needs more context. |
283
+ | `deep` | 5 | Pro and above | Comprehensive analysis without catch-all probing. |
284
+ | `catch_all` | 20 | Enterprise or entitled accounts | Deep analysis with catch-all probing. |
285
+
286
+ Use the default quick profile:
287
+
288
+ ```python
289
+ result = client.smtprs.analyze("person@example.com")
290
+ ```
291
+
292
+ Or request another profile available to your account:
293
+
294
+ ```python
295
+ result = client.smtprs.analyze(
296
+ "person@example.com",
297
+ profile="standard",
298
+ )
299
+ ```
300
+
301
+ Profile availability is enforced by the API. Requesting a profile that is not included
302
+ with the current plan raises `PermissionDeniedError`.
303
+
304
+ ### Legacy mode flags
305
+
306
+ The older `disposable_only`, `strict_disposable`, `guess`, and `run_catch_all` arguments
307
+ remain available for compatibility. New integrations should use `profile` instead. Do not
308
+ combine `profile` with an enabled legacy mode flag.
309
+
310
+ For example, replace:
311
+
312
+ ```python
313
+ result = client.smtprs.analyze(
314
+ "person@example.com",
315
+ strict_disposable=True,
316
+ )
317
+ ```
318
+
319
+ with:
320
+
321
+ ```python
322
+ result = client.smtprs.analyze(
323
+ "person@example.com",
324
+ profile="deep",
325
+ )
326
+ ```
327
+
328
+ ### Low-latency posture
329
+
330
+ Use the `fast` flag when your workflow favors lower latency:
331
+
332
+ ```python
333
+ result = client.smtprs.analyze(
334
+ "person@example.com",
335
+ fast=True,
336
+ )
337
+ ```
338
+
339
+ ### Company Validity Beta
340
+
341
+ Paid smtpRS callers can explicitly request the optional Company Validity Beta:
342
+
343
+ ```python
344
+ result = client.smtprs.analyze(
345
+ "person@example.com",
346
+ profile="standard",
347
+ company_validity_beta=True,
348
+ )
349
+
350
+ beta = result.company_validity_beta
351
+ if beta is not None:
352
+ print(beta.status) # "beta"
353
+ print(beta.requested) # True
354
+ print(beta.enabled) # True when the feature was active for this request
355
+ print(beta.notes)
356
+
357
+ signal = result.domain_signal
358
+ if signal is not None:
359
+ print(signal.domain_status)
360
+ print(signal.mail_status)
361
+ print(signal.company_valid)
362
+ ```
363
+
364
+ The option is omitted by default, so existing calls retain their current
365
+ behavior. It is available only to paid smtpRS plans and provides additive
366
+ company-domain context rather than an allowlist. It currently adds no credits
367
+ beyond the selected profile. Free-plan requests that explicitly enable it
368
+ receive `PermissionDeniedError`. Because the response contract is beta,
369
+ applications can use `result.to_dict()` to retain access to newly added fields.
370
+
371
+ ## Configuration
372
+
373
+ ```python
374
+ from paravane import ParavaneClient
375
+
376
+ client = ParavaneClient(
377
+ api_key="pvn_live_...",
378
+ base_url="https://api.paravane.io",
379
+ timeout=20.0,
380
+ max_network_retries=1,
381
+ )
382
+ ```
383
+
384
+ Environment variables:
385
+
386
+ | Name | Purpose | Default |
387
+ | --- | --- | --- |
388
+ | `PARAVANE_API_KEY` | API key used for requests. | None |
389
+ | `PARAVANE_BASE_URL` | API base URL. | `https://api.paravane.io` |
390
+
391
+ ## Per-request options
392
+
393
+ You can set a timeout for one request:
394
+
395
+ ```python
396
+ result = client.smtprs.analyze(
397
+ "person@example.com",
398
+ timeout=5.0,
399
+ )
400
+ ```
401
+
402
+ You can pass an idempotency key:
403
+
404
+ ```python
405
+ result = client.smtprs.analyze(
406
+ "person@example.com",
407
+ idempotency_key="signup-check-123",
408
+ )
409
+ ```
410
+
411
+ You can pass future or preview query parameters without waiting for a new SDK release:
412
+
413
+ ```python
414
+ result = client.smtprs.analyze(
415
+ "person@example.com",
416
+ extra_params={"preview_flag": "enabled"},
417
+ )
418
+ ```
419
+
420
+ ## Custom HTTP sessions
421
+
422
+ The SDK uses `requests` by default. If your environment needs custom connection pooling, proxies, certificates, or adapters, pass a configured `requests.Session`:
423
+
424
+ ```python
425
+ import requests
426
+ from paravane import ParavaneClient
427
+
428
+ session = requests.Session()
429
+ session.proxies.update({
430
+ "https": "https://proxy.example.com:8443",
431
+ })
432
+
433
+ client = ParavaneClient(
434
+ api_key="pvn_live_...",
435
+ session=session,
436
+ )
437
+ ```
438
+
439
+ ## Retries
440
+
441
+ By default, the SDK does not retry network calls:
442
+
443
+ ```python
444
+ client = ParavaneClient(max_network_retries=0)
445
+ ```
446
+
447
+ Enable limited retries for transient network failures, `429` rate limits, and `5xx` API responses:
448
+
449
+ ```python
450
+ client = ParavaneClient(max_network_retries=2)
451
+ ```
452
+
453
+ When enabling retries for write-like workflows, pass an idempotency key from your application so repeated attempts can be treated safely by the API:
454
+
455
+ ```python
456
+ result = client.smtprs.analyze(
457
+ "person@example.com",
458
+ idempotency_key="customer-signup-456",
459
+ )
460
+ ```
461
+
462
+ ## Idempotency
463
+
464
+ The SDK accepts an `idempotency_key` and sends it as:
465
+
466
+ ```text
467
+ Idempotency-Key: your-key
468
+ ```
469
+
470
+ Use stable keys for requests that your application may retry after timeouts or transient failures.
471
+
472
+ ## Errors
473
+
474
+ Unsuccessful requests raise structured exceptions from `paravane.errors`.
475
+
476
+ ```python
477
+ from paravane import (
478
+ AuthenticationError,
479
+ ParavaneClient,
480
+ QuotaExceededError,
481
+ RateLimitError,
482
+ ValidationError,
483
+ )
484
+
485
+ client = ParavaneClient()
486
+
487
+ try:
488
+ result = client.smtprs.analyze("person@example.com")
489
+ except AuthenticationError:
490
+ print("Check your API key.")
491
+ except QuotaExceededError:
492
+ print("The workspace has exhausted its available credits.")
493
+ except RateLimitError:
494
+ print("Slow down and retry later.")
495
+ except ValidationError as exc:
496
+ print("Request was invalid:", exc)
497
+ ```
498
+
499
+ Exception classes:
500
+
501
+ | Class | Typical cause |
502
+ | --- | --- |
503
+ | `ConfigurationError` | Missing API key or invalid client setup. |
504
+ | `APIConnectionError` | Network failure, timeout, DNS failure, or connection error. |
505
+ | `APIError` | Generic non-success API response. |
506
+ | `AuthenticationError` | Missing, invalid, or revoked API key. |
507
+ | `PermissionDeniedError` | API key lacks access to the requested resource. |
508
+ | `ValidationError` | Invalid request payload or parameters. |
509
+ | `QuotaExceededError` | Plan or credit quota has been exhausted. |
510
+ | `RateLimitError` | Too many requests. |
511
+
512
+ API errors include useful details when available:
513
+
514
+ ```python
515
+ try:
516
+ client.smtprs.analyze("not-an-email")
517
+ except ValidationError as exc:
518
+ print(exc.status_code)
519
+ print(exc.code)
520
+ print(exc.request_id)
521
+ print(exc.response)
522
+ ```
523
+
524
+ ## Responses and raw data
525
+
526
+ The SDK returns `SmtpRsAnalysis` for smtpRS analysis requests.
527
+
528
+ ```python
529
+ result = client.smtprs.analyze("person@example.com")
530
+ ```
531
+
532
+ Common fields:
533
+
534
+ | Field | Description |
535
+ | --- | --- |
536
+ | `email` | Email address represented by the response, when returned by the API. |
537
+ | `decision` | Customer-facing decision or recommendation, when returned by the API. |
538
+ | `overall_risk` | Overall risk score, when returned by the API. |
539
+ | `tier` | Workspace/API-key tier reflected by the response. |
540
+ | `analysis_profile` | Analysis path used by the API. |
541
+ | `response_profile` | Response shape, such as summary/full. |
542
+ | `credit_cost` | Planned credit cost for the selected profile. |
543
+ | `credits_charged` | Credits recorded for the request, when returned. |
544
+ | `company_validity_beta` | Typed beta status, opt-in state, availability, credit cost, and API notes. |
545
+ | `domain_signal` | Typed domain, mail, and company-context facts when returned. |
546
+ | `reasons` | Human-readable reason strings, when returned. |
547
+ | `usage` | Usage/quota snapshot, when returned. |
548
+ | `raw` | Original API payload. |
549
+
550
+ To avoid losing fields added by the API before the SDK is updated, the full payload is always preserved:
551
+
552
+ ```python
553
+ raw = result.to_dict()
554
+ print(raw["usage"])
555
+ ```
556
+
557
+ ## Types
558
+
559
+ This package includes inline type hints and ships a `py.typed` marker.
560
+
561
+ The response helper is a dataclass:
562
+
563
+ ```python
564
+ from paravane import SmtpRsAnalysis
565
+
566
+ def handle_result(result: SmtpRsAnalysis) -> None:
567
+ print(result.decision)
568
+ signal = result.domain_signal
569
+ if signal is not None:
570
+ print(signal.mail_capable)
571
+ ```
572
+
573
+ Type hints are intended to describe stable SDK behavior. The raw API response may include additional fields that are not represented as first-class dataclass attributes yet.
574
+
575
+ ## Logging
576
+
577
+ The SDK does not install or configure logging handlers. Applications should configure logging at the application boundary.
578
+
579
+ For now, request failures are surfaced through exceptions. If you need detailed HTTP logging during development, configure your own `requests.Session` or enable logging in your HTTP stack.
580
+
581
+ ## Examples
582
+
583
+ This repository includes small examples:
584
+
585
+ ```text
586
+ examples/basic_analyze.py
587
+ examples/strict_analyze.py
588
+ examples/batch_csv.py
589
+ ```
590
+
591
+ Run one with:
592
+
593
+ ```bash
594
+ PARAVANE_API_KEY="pvn_live_..." python examples/basic_analyze.py
595
+ ```
596
+
597
+ Windows PowerShell:
598
+
599
+ ```powershell
600
+ $env:PARAVANE_API_KEY = "pvn_live_..."
601
+ python examples/basic_analyze.py
602
+ ```
603
+
604
+ ## Development
605
+
606
+ Create an environment:
607
+
608
+ ```bash
609
+ python -m venv .venv
610
+ source .venv/bin/activate
611
+ python -m pip install -e ".[dev]"
612
+ ```
613
+
614
+ Windows PowerShell:
615
+
616
+ ```powershell
617
+ python -m venv .venv
618
+ .\.venv\Scripts\Activate.ps1
619
+ python -m pip install -e ".[dev]"
620
+ ```
621
+
622
+ Run tests:
623
+
624
+ ```bash
625
+ pytest
626
+ ```
627
+
628
+ Run lint:
629
+
630
+ ```bash
631
+ ruff check .
632
+ ```
633
+
634
+ Build the package:
635
+
636
+ ```bash
637
+ python -m build
638
+ ```
639
+
640
+ Recommended pre-commit check:
641
+
642
+ ```bash
643
+ pytest && ruff check . && python -m build
644
+ ```
645
+
646
+ ## Repository layout
647
+
648
+ ```text
649
+ paravane-python/
650
+ .github/workflows/ GitHub Actions checks
651
+ examples/ Small runnable examples
652
+ src/paravane/ SDK package source
653
+ tests/ Unit tests
654
+ pyproject.toml Packaging metadata and tool config
655
+ RELEASING.md Maintainer release process
656
+ ```
657
+
658
+ ## Versioning
659
+
660
+ Current package version:
661
+
662
+ ```text
663
+ 0.2.0
664
+ ```
665
+
666
+ The SDK is in an early release phase. Until `1.0.0`, minor versions may refine naming, response helpers, or packaging behavior as the public API settles. Runtime-breaking changes should still be called out clearly in `CHANGELOG.md`.
667
+
668
+ ## Security
669
+
670
+ Do not put API keys in source code, client-side apps, mobile apps, screenshots, or public repositories.
671
+
672
+ Recommended handling:
673
+
674
+ - load API keys from environment variables or a secret manager
675
+ - rotate keys if they are exposed
676
+ - create separate keys for development, staging, and production
677
+ - revoke keys that are no longer needed
678
+
679
+ Report suspected vulnerabilities privately:
680
+
681
+ ```text
682
+ security@paravane.io
683
+ ```
684
+
685
+ ## Support
686
+
687
+ For product or account support:
688
+
689
+ ```text
690
+ contact@paravane.io
691
+ ```
692
+
693
+ For security reports:
694
+
695
+ ```text
696
+ security@paravane.io
697
+ ```
@@ -0,0 +1,13 @@
1
+ paravane/__init__.py,sha256=qB07T2g7D2jPtF_22rx2l0AviLA0gVUxZlgDaf7rgjA,834
2
+ paravane/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
3
+ paravane/client.py,sha256=KGXFuFedfxB860hzjeXKIyPwm-U5Y8JJj8u2zpuwlPI,1515
4
+ paravane/errors.py,sha256=IQOFf3pljM1X1CWei6fqICYiBIhtCwo9FOCC4BWyDiI,1810
5
+ paravane/http.py,sha256=x4kjF0jFvwgoFLFNkcV1suSeRrYyn7-_dx9d26bCrw8,5808
6
+ paravane/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ paravane/smtprs.py,sha256=OjMOogHzkAt7W46WZLvJvAD96v5D8sgLTPRmng7CBNg,2437
8
+ paravane/types.py,sha256=rcivfo6bzo7ou3DsQLEkV8XubzkuwqTX8dXV0UJ4qG8,6998
9
+ paravane-0.2.0.dist-info/licenses/LICENSE,sha256=BY5W6fvNfOx_sbPT4fL55H0nWP1K_xptQXGRaQ_4eSg,1070
10
+ paravane-0.2.0.dist-info/METADATA,sha256=rXGftPy12EUzq1JgsTfxHC9AuSlDWzSHgm_JlxwqQxo,18288
11
+ paravane-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ paravane-0.2.0.dist-info/top_level.txt,sha256=R-DBWERALLdrqDACB9XN4eJPEw88dIUdsaLOlgWdK60,9
13
+ paravane-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paravane Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ paravane