archivist-py 1.0.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.
archivist/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """Preserve the web and retrieve archives with a typed Python API."""
2
+
3
+ from archivist.core import (
4
+ Archiver,
5
+ ArchiveRecord,
6
+ ArchivistError,
7
+ AsyncArchiver,
8
+ AsyncSearcher,
9
+ AuthenticationError,
10
+ CaptureFailedError,
11
+ CaptureJob,
12
+ InvalidOptionError,
13
+ InvalidServiceResponseError,
14
+ InvalidTargetURLError,
15
+ InvalidURLError,
16
+ NetworkError,
17
+ OptionCombinationError,
18
+ PagedSearchResult,
19
+ PollingTimeoutError,
20
+ RateLimitError,
21
+ Searcher,
22
+ ServiceError,
23
+ ServiceStatus,
24
+ TLSVerificationError,
25
+ )
26
+ from archivist.services.archive_today import (
27
+ ArchiveTodayClient,
28
+ ArchiveTodayMemento,
29
+ ArchiveTodayRecentCapture,
30
+ ArchiveTodayRecentFeed,
31
+ ArchiveTodayTimeMap,
32
+ AsyncArchiveTodayClient,
33
+ )
34
+ from archivist.services.internet_archive import (
35
+ AsyncInternetArchiveClient,
36
+ InternetArchiveAccount,
37
+ InternetArchiveApiKey,
38
+ InternetArchiveAvailability,
39
+ InternetArchiveCaptureJob,
40
+ InternetArchiveCaptureStatus,
41
+ InternetArchiveCdxRecord,
42
+ InternetArchiveCdxResult,
43
+ InternetArchiveClient,
44
+ InternetArchiveCookies,
45
+ InternetArchiveFailedStatus,
46
+ InternetArchivePendingStatus,
47
+ InternetArchiveSaveOptions,
48
+ InternetArchiveSuccessStatus,
49
+ InternetArchiveSystemStatus,
50
+ InternetArchiveUserStatus,
51
+ )
52
+
53
+ __version__ = "1.0.0"
54
+
55
+ __all__ = [
56
+ "ArchiveRecord",
57
+ "ArchiveTodayClient",
58
+ "ArchiveTodayMemento",
59
+ "ArchiveTodayRecentCapture",
60
+ "ArchiveTodayRecentFeed",
61
+ "ArchiveTodayTimeMap",
62
+ "Archiver",
63
+ "ArchivistError",
64
+ "AsyncArchiveTodayClient",
65
+ "AsyncArchiver",
66
+ "AsyncInternetArchiveClient",
67
+ "AsyncSearcher",
68
+ "AuthenticationError",
69
+ "CaptureFailedError",
70
+ "CaptureJob",
71
+ "InternetArchiveAccount",
72
+ "InternetArchiveApiKey",
73
+ "InternetArchiveAvailability",
74
+ "InternetArchiveCaptureJob",
75
+ "InternetArchiveCaptureStatus",
76
+ "InternetArchiveCdxRecord",
77
+ "InternetArchiveCdxResult",
78
+ "InternetArchiveClient",
79
+ "InternetArchiveCookies",
80
+ "InternetArchiveFailedStatus",
81
+ "InternetArchivePendingStatus",
82
+ "InternetArchiveSaveOptions",
83
+ "InternetArchiveSuccessStatus",
84
+ "InternetArchiveSystemStatus",
85
+ "InternetArchiveUserStatus",
86
+ "InvalidOptionError",
87
+ "InvalidServiceResponseError",
88
+ "InvalidTargetURLError",
89
+ "InvalidURLError",
90
+ "NetworkError",
91
+ "OptionCombinationError",
92
+ "PagedSearchResult",
93
+ "PollingTimeoutError",
94
+ "RateLimitError",
95
+ "Searcher",
96
+ "ServiceError",
97
+ "ServiceStatus",
98
+ "TLSVerificationError",
99
+ "__version__",
100
+ ]
@@ -0,0 +1,52 @@
1
+ """Shared models, protocols, and exceptions."""
2
+
3
+ import logging
4
+
5
+ from archivist.core.errors import (
6
+ ArchivistError,
7
+ AuthenticationError,
8
+ CaptureFailedError,
9
+ InvalidOptionError,
10
+ InvalidServiceResponseError,
11
+ InvalidTargetURLError,
12
+ InvalidURLError,
13
+ NetworkError,
14
+ OptionCombinationError,
15
+ PollingTimeoutError,
16
+ RateLimitError,
17
+ ServiceError,
18
+ TLSVerificationError,
19
+ )
20
+ from archivist.core.models import (
21
+ ArchiveRecord,
22
+ CaptureJob,
23
+ PagedSearchResult,
24
+ ServiceStatus,
25
+ )
26
+ from archivist.core.protocols import Archiver, AsyncArchiver, AsyncSearcher, Searcher
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ __all__ = [
31
+ "ArchiveRecord",
32
+ "Archiver",
33
+ "ArchivistError",
34
+ "AsyncArchiver",
35
+ "AsyncSearcher",
36
+ "AuthenticationError",
37
+ "CaptureFailedError",
38
+ "CaptureJob",
39
+ "InvalidOptionError",
40
+ "InvalidServiceResponseError",
41
+ "InvalidTargetURLError",
42
+ "InvalidURLError",
43
+ "NetworkError",
44
+ "OptionCombinationError",
45
+ "PagedSearchResult",
46
+ "PollingTimeoutError",
47
+ "RateLimitError",
48
+ "Searcher",
49
+ "ServiceError",
50
+ "ServiceStatus",
51
+ "TLSVerificationError",
52
+ ]
@@ -0,0 +1,261 @@
1
+ """Shared HTTP response and exception handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ from collections.abc import Mapping
8
+ from datetime import UTC, datetime
9
+ from email.utils import parsedate_to_datetime
10
+ from inspect import isawaitable
11
+ from typing import Any, Protocol, cast
12
+
13
+ import niquests
14
+
15
+ from archivist.core.errors import (
16
+ AuthenticationError,
17
+ InvalidServiceResponseError,
18
+ NetworkError,
19
+ RateLimitError,
20
+ TLSVerificationError,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _HTTP_ERROR_STATUS = 400
26
+ _HTTP_RATE_LIMIT_STATUS = 429
27
+
28
+
29
+ class ResponseLike(Protocol):
30
+ status_code: int
31
+ headers: Mapping[str, str]
32
+ text: str
33
+ url: str
34
+
35
+ def json(self) -> object:
36
+ """Decode the response as JSON."""
37
+ ...
38
+
39
+
40
+ class AsyncJsonResponseLike(Protocol):
41
+ """Expose fields consumed while decoding an asynchronous JSON response."""
42
+
43
+ status_code: int
44
+
45
+ def json(self) -> object:
46
+ """Decode the response as JSON, possibly returning an awaitable."""
47
+ ...
48
+
49
+
50
+ class AsyncTextResponseLike(Protocol):
51
+ """Expose fields consumed while decoding an asynchronous text response."""
52
+
53
+ status_code: int
54
+ text: object
55
+
56
+
57
+ def parse_retry_after(
58
+ headers: Mapping[str, str],
59
+ ) -> tuple[float | datetime | None, str | None]:
60
+ """Parse Retry-After while preserving an unrecognized raw value."""
61
+ raw = headers.get("Retry-After") or headers.get("retry-after")
62
+ if raw is None:
63
+ return None, None
64
+ value = raw.strip()
65
+ try:
66
+ seconds = float(value)
67
+ except ValueError:
68
+ try:
69
+ date = parsedate_to_datetime(value)
70
+ except (TypeError, ValueError, OverflowError):
71
+ return None, raw
72
+ if date.tzinfo is None:
73
+ date = date.replace(tzinfo=UTC)
74
+ return date.astimezone(UTC), raw
75
+ if not math.isfinite(seconds):
76
+ return None, raw
77
+ return max(0.0, seconds), raw
78
+
79
+
80
+ def response_json(response: ResponseLike, *, service: str) -> object:
81
+ """Decode JSON or raise a body-safe service response error."""
82
+ try:
83
+ return response.json()
84
+ except niquests.exceptions.JSONDecodeError:
85
+ error = None
86
+ except (niquests.exceptions.RequestException, OSError) as exc:
87
+ error = translate_request_error(exc, service=service)
88
+ except (TypeError, ValueError):
89
+ error = None
90
+ if error is not None:
91
+ raise error from None
92
+ logger.warning("%s returned invalid JSON", service)
93
+ raise InvalidServiceResponseError(
94
+ f"{service} returned invalid JSON",
95
+ service=service,
96
+ status_code=response.status_code,
97
+ ) from None
98
+
99
+
100
+ def response_mapping(response: ResponseLike, *, service: str) -> Mapping[str, Any]:
101
+ """Decode a JSON object response."""
102
+ value = response_json(response, service=service)
103
+ if not isinstance(value, Mapping):
104
+ logger.warning("%s returned JSON with an unexpected shape", service)
105
+ raise InvalidServiceResponseError(
106
+ f"{service} returned JSON with an unexpected shape",
107
+ service=service,
108
+ status_code=response.status_code,
109
+ )
110
+ return cast("Mapping[str, Any]", value)
111
+
112
+
113
+ async def async_response_json(response: object, *, service: str) -> object:
114
+ """Decode either a regular or multiplexed niquests async response."""
115
+ typed = cast("AsyncJsonResponseLike", response)
116
+ try:
117
+ value = typed.json()
118
+ if isawaitable(value):
119
+ value = await value
120
+ return value
121
+ except niquests.exceptions.JSONDecodeError:
122
+ error = None
123
+ except (niquests.exceptions.RequestException, OSError) as exc:
124
+ error = translate_request_error(exc, service=service)
125
+ except (TypeError, ValueError):
126
+ error = None
127
+ if error is not None:
128
+ raise error from None
129
+ logger.warning("%s returned invalid JSON", service)
130
+ raise InvalidServiceResponseError(
131
+ f"{service} returned invalid JSON",
132
+ service=service,
133
+ status_code=typed.status_code,
134
+ ) from None
135
+
136
+
137
+ async def async_response_mapping(
138
+ response: object, *, service: str
139
+ ) -> Mapping[str, Any]:
140
+ """Decode an async JSON object response."""
141
+ value = await async_response_json(response, service=service)
142
+ if not isinstance(value, Mapping):
143
+ logger.warning("%s returned JSON with an unexpected shape", service)
144
+ raise InvalidServiceResponseError(
145
+ f"{service} returned JSON with an unexpected shape",
146
+ service=service,
147
+ status_code=cast("AsyncJsonResponseLike", response).status_code,
148
+ )
149
+ return cast("Mapping[str, Any]", value)
150
+
151
+
152
+ def response_text(response: ResponseLike, *, service: str) -> str:
153
+ """Read a regular response body and require decoded text."""
154
+ try:
155
+ value = response.text
156
+ except (niquests.exceptions.RequestException, OSError) as exc:
157
+ error = translate_request_error(exc, service=service)
158
+ else:
159
+ error = None
160
+ if error is not None:
161
+ raise error from None
162
+ if not isinstance(value, str):
163
+ logger.warning("%s returned a non-text response", service)
164
+ raise InvalidServiceResponseError(
165
+ f"{service} returned a non-text response",
166
+ service=service,
167
+ status_code=response.status_code,
168
+ )
169
+ return value
170
+
171
+
172
+ async def async_response_text(response: object, *, service: str) -> str:
173
+ """Read text from either niquests async response representation."""
174
+ typed = cast("AsyncTextResponseLike", response)
175
+ try:
176
+ value = typed.text
177
+ if isawaitable(value):
178
+ value = await value
179
+ except (niquests.exceptions.RequestException, OSError) as exc:
180
+ error = translate_request_error(exc, service=service)
181
+ else:
182
+ error = None
183
+ if error is not None:
184
+ raise error from None
185
+ if not isinstance(value, str):
186
+ logger.warning("%s returned a non-text response", service)
187
+ raise InvalidServiceResponseError(
188
+ f"{service} returned a non-text response",
189
+ service=service,
190
+ status_code=typed.status_code,
191
+ )
192
+ return value
193
+
194
+
195
+ def raise_for_common_status(response: ResponseLike, *, service: str) -> None:
196
+ """Translate common HTTP failures without retaining response bodies."""
197
+ status_code = response.status_code
198
+ if status_code in {401, 403}:
199
+ logger.warning(
200
+ "%s rejected request credentials with HTTP %s", service, status_code
201
+ )
202
+ raise AuthenticationError(
203
+ f"{service} rejected the request credentials",
204
+ service=service,
205
+ status_code=status_code,
206
+ )
207
+ if status_code == _HTTP_RATE_LIMIT_STATUS:
208
+ retry_after, raw = parse_retry_after(response.headers)
209
+ logger.warning("%s rate limit reached", service)
210
+ raise RateLimitError(
211
+ f"{service} rate limit reached",
212
+ service=service,
213
+ status_code=status_code,
214
+ retry_after=retry_after,
215
+ retry_after_raw=raw,
216
+ )
217
+ if status_code >= _HTTP_ERROR_STATUS:
218
+ logger.warning("%s returned HTTP %s", service, status_code)
219
+ raise InvalidServiceResponseError(
220
+ f"{service} returned HTTP {status_code}",
221
+ service=service,
222
+ status_code=status_code,
223
+ )
224
+
225
+
226
+ def translate_request_error(exc: BaseException, *, service: str) -> NetworkError:
227
+ """Convert a transport exception without copying its potentially sensitive text."""
228
+ type_name = type(exc).__name__.lower()
229
+ if "ssl" in type_name or "tls" in type_name:
230
+ return TLSVerificationError(
231
+ f"TLS request to {service} failed",
232
+ service=service,
233
+ cause_type=type(exc).__name__,
234
+ )
235
+ return NetworkError(
236
+ f"network request to {service} failed",
237
+ service=service,
238
+ cause_type=type(exc).__name__,
239
+ )
240
+
241
+
242
+ _SECRET_KEYS = frozenset(
243
+ {
244
+ "authorization",
245
+ "cookie",
246
+ "capture_cookie",
247
+ "password",
248
+ "target_password",
249
+ "logged-in-sig",
250
+ "logged-in-user",
251
+ "secret_key",
252
+ }
253
+ )
254
+
255
+
256
+ def redact_mapping(values: Mapping[str, object]) -> dict[str, object]:
257
+ """Return a shallow, log-safe copy of request metadata."""
258
+ return {
259
+ key: "<redacted>" if key.lower() in _SECRET_KEYS else value
260
+ for key, value in values.items()
261
+ }
@@ -0,0 +1,80 @@
1
+ """URL validation and safe log rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
7
+
8
+ from archivist.core.errors import InvalidTargetURLError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _ASCII_CONTROL_LIMIT = 32
13
+
14
+
15
+ def _split_http_url(url: str, *, label: str) -> SplitResult:
16
+ if not isinstance(url, str) or not url:
17
+ raise InvalidTargetURLError(f"{label} must be a non-empty string")
18
+ if any(
19
+ character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT
20
+ for character in url
21
+ ):
22
+ raise InvalidTargetURLError(
23
+ f"{label} cannot contain whitespace or control characters"
24
+ )
25
+ try:
26
+ parsed = urlsplit(url)
27
+ _ = parsed.port
28
+ except ValueError as exc:
29
+ raise InvalidTargetURLError(f"{label} is not a valid URL") from exc
30
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
31
+ raise InvalidTargetURLError(f"{label} must be an absolute HTTP or HTTPS URL")
32
+ return parsed
33
+
34
+
35
+ def validate_target_url(url: str) -> str:
36
+ """Validate an archive target without changing its wire representation."""
37
+ _split_http_url(url, label="target URL")
38
+ return url
39
+
40
+
41
+ def validate_cdx_query(query: str) -> str:
42
+ """Validate an absolute URL or wildcard expression accepted by CDX."""
43
+ if not isinstance(query, str) or not query:
44
+ raise InvalidTargetURLError("CDX query must be a non-empty string")
45
+ if any(
46
+ character.isspace() or ord(character) < _ASCII_CONTROL_LIMIT
47
+ for character in query
48
+ ):
49
+ raise InvalidTargetURLError(
50
+ "CDX query cannot contain whitespace or control characters"
51
+ )
52
+ if "://" in query:
53
+ validate_target_url(query)
54
+ return query
55
+
56
+
57
+ def validate_service_url(url: str) -> str:
58
+ """Validate and normalize a configurable archive service base URL."""
59
+ parsed = _split_http_url(url, label="service URL")
60
+ if parsed.username is not None or parsed.password is not None:
61
+ raise InvalidTargetURLError("service URL cannot contain user information")
62
+ if "?" in url or "#" in url:
63
+ raise InvalidTargetURLError("service URL cannot contain a query or fragment")
64
+ return url.rstrip("/")
65
+
66
+
67
+ def sanitize_url_for_log(url: str) -> str:
68
+ """Remove user information, query parameters, and fragments from a URL."""
69
+ try:
70
+ parsed = urlsplit(url)
71
+ hostname = parsed.hostname
72
+ if not parsed.scheme or not hostname:
73
+ return "<invalid-url>"
74
+ if ":" in hostname and not hostname.startswith("["):
75
+ hostname = f"[{hostname}]"
76
+ port = parsed.port
77
+ netloc = f"{hostname}:{port}" if port is not None else hostname
78
+ return urlunsplit((parsed.scheme, netloc, parsed.path or "/", "", ""))
79
+ except (TypeError, ValueError):
80
+ return "<invalid-url>"
@@ -0,0 +1,124 @@
1
+ """Exceptions raised by Archivist clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ArchivistError(Exception):
12
+ """Base class for all package exceptions."""
13
+
14
+
15
+ class InvalidTargetURLError(ArchivistError, ValueError):
16
+ """The target is not an absolute HTTP or HTTPS URL."""
17
+
18
+
19
+ InvalidURLError = InvalidTargetURLError
20
+
21
+
22
+ class InvalidOptionError(ArchivistError, ValueError):
23
+ """A client option has an invalid value."""
24
+
25
+
26
+ class OptionCombinationError(InvalidOptionError):
27
+ """Two or more otherwise valid options cannot be used together."""
28
+
29
+
30
+ class ServiceError(ArchivistError):
31
+ """An archive service request failed."""
32
+
33
+ def __init__(
34
+ self,
35
+ message: str,
36
+ *,
37
+ service: str | None = None,
38
+ status_code: int | None = None,
39
+ ) -> None:
40
+ """Initialize the error with service response context."""
41
+ super().__init__(message)
42
+ self.service = service
43
+ self.status_code = status_code
44
+
45
+
46
+ class NetworkError(ServiceError):
47
+ """A request failed before a usable HTTP response was received."""
48
+
49
+ def __init__(
50
+ self,
51
+ message: str,
52
+ *,
53
+ service: str | None = None,
54
+ status_code: int | None = None,
55
+ cause_type: str | None = None,
56
+ ) -> None:
57
+ """Initialize the error with transport failure context."""
58
+ super().__init__(message, service=service, status_code=status_code)
59
+ self.cause_type = cause_type
60
+
61
+
62
+ class TLSVerificationError(NetworkError):
63
+ """TLS negotiation or certificate verification failed."""
64
+
65
+
66
+ class AuthenticationError(ServiceError):
67
+ """A service rejected or requires the supplied credentials."""
68
+
69
+
70
+ class RateLimitError(ServiceError):
71
+ """A service refused a request because a rate limit was reached."""
72
+
73
+ def __init__(
74
+ self,
75
+ message: str,
76
+ *,
77
+ service: str | None = None,
78
+ status_code: int | None = None,
79
+ retry_after: float | datetime | None = None,
80
+ retry_after_raw: str | None = None,
81
+ ) -> None:
82
+ """Initialize the error with retry timing information."""
83
+ super().__init__(message, service=service, status_code=status_code)
84
+ self.retry_after = retry_after
85
+ self.retry_after_raw = retry_after_raw
86
+
87
+
88
+ class InvalidServiceResponseError(ServiceError):
89
+ """A service returned an undocumented or malformed response."""
90
+
91
+
92
+ class CaptureFailedError(ServiceError):
93
+ """A service accepted a capture job but later reported failure."""
94
+
95
+ def __init__(
96
+ self,
97
+ message: str,
98
+ *,
99
+ service: str | None = None,
100
+ status_code: int | None = None,
101
+ job_id: str | None = None,
102
+ service_code: str | None = None,
103
+ ) -> None:
104
+ """Initialize the error with failed capture details."""
105
+ super().__init__(message, service=service, status_code=status_code)
106
+ self.job_id = job_id
107
+ self.service_code = service_code
108
+
109
+
110
+ class PollingTimeoutError(ServiceError, TimeoutError):
111
+ """A capture did not reach a terminal state before its deadline."""
112
+
113
+ def __init__(
114
+ self,
115
+ message: str,
116
+ *,
117
+ service: str | None = None,
118
+ job_id: str | None = None,
119
+ timeout: float | None = None,
120
+ ) -> None:
121
+ """Initialize the error with polling deadline details."""
122
+ super().__init__(message, service=service)
123
+ self.job_id = job_id
124
+ self.timeout = timeout
@@ -0,0 +1,83 @@
1
+ """Service-independent Archivist models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime
9
+ from typing import Generic, TypeVar
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _as_utc(value: datetime | None) -> datetime | None:
15
+ if value is None:
16
+ return None
17
+ if value.tzinfo is None or value.utcoffset() is None:
18
+ raise ValueError("timestamps must include timezone information")
19
+ return value.astimezone(UTC)
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ArchiveRecord:
24
+ """A capture returned by an archive service."""
25
+
26
+ service: str
27
+ archive_url: str = field(repr=False)
28
+ original_url: str | None = field(default=None, repr=False)
29
+ archived_at: datetime | None = None
30
+ capture_id: str | None = None
31
+
32
+ def __post_init__(self) -> None:
33
+ """Normalize the archive timestamp to UTC."""
34
+ object.__setattr__(self, "archived_at", _as_utc(self.archived_at))
35
+
36
+
37
+ ItemT = TypeVar("ItemT")
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class PagedSearchResult(Generic[ItemT]):
42
+ """One page of service search results."""
43
+
44
+ items: tuple[ItemT, ...]
45
+ page: int = 1
46
+ page_count: int = 1
47
+ total_count: int | None = None
48
+
49
+ def __post_init__(self) -> None:
50
+ """Validate pagination metadata."""
51
+ if self.page < 1:
52
+ raise ValueError("page must be at least 1")
53
+ if self.page_count < 1:
54
+ raise ValueError("page_count must be at least 1")
55
+ if self.total_count is not None and self.total_count < 0:
56
+ raise ValueError("total_count cannot be negative")
57
+
58
+ def __iter__(self) -> Iterator[ItemT]:
59
+ """Iterate over the items on this page."""
60
+ return iter(self.items)
61
+
62
+ def __len__(self) -> int:
63
+ """Return the number of items on this page."""
64
+ return len(self.items)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class CaptureJob:
69
+ """An accepted asynchronous capture job."""
70
+
71
+ service: str
72
+ job_id: str
73
+ target_url: str = field(repr=False)
74
+ message: str | None = field(default=None, repr=False)
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class ServiceStatus:
79
+ """A service health summary."""
80
+
81
+ service: str
82
+ status: str
83
+ message: str | None = None