reqkey 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (26) hide show
  1. {reqkey-0.1.0 → reqkey-0.2.0}/PKG-INFO +123 -4
  2. {reqkey-0.1.0 → reqkey-0.2.0}/README.md +122 -3
  3. {reqkey-0.1.0 → reqkey-0.2.0}/pyproject.toml +1 -1
  4. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/__init__.py +3 -2
  5. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/_middleware.py +66 -0
  6. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/asgi.py +131 -7
  7. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/client.py +7 -1
  8. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/django.py +227 -11
  9. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/models.py +19 -2
  10. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/wsgi.py +129 -12
  11. {reqkey-0.1.0 → reqkey-0.2.0}/tests/test_client.py +2 -0
  12. {reqkey-0.1.0 → reqkey-0.2.0}/tests/test_django.py +179 -4
  13. {reqkey-0.1.0 → reqkey-0.2.0}/tests/test_fastapi.py +89 -2
  14. reqkey-0.2.0/tests/test_middleware.py +35 -0
  15. {reqkey-0.1.0 → reqkey-0.2.0}/tests/test_wsgi.py +122 -3
  16. {reqkey-0.1.0 → reqkey-0.2.0}/.github/workflows/ci.yml +0 -0
  17. {reqkey-0.1.0 → reqkey-0.2.0}/.github/workflows/release.yml +0 -0
  18. {reqkey-0.1.0 → reqkey-0.2.0}/.gitignore +0 -0
  19. {reqkey-0.1.0 → reqkey-0.2.0}/LICENSE +0 -0
  20. {reqkey-0.1.0 → reqkey-0.2.0}/examples/django_settings.py +0 -0
  21. {reqkey-0.1.0 → reqkey-0.2.0}/examples/fastapi_app.py +0 -0
  22. {reqkey-0.1.0 → reqkey-0.2.0}/examples/flask_app.py +0 -0
  23. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/exceptions.py +0 -0
  24. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/fastapi.py +0 -0
  25. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/flask.py +0 -0
  26. {reqkey-0.1.0 → reqkey-0.2.0}/src/reqkey/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reqkey
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The official Python SDK for ReqKey API key validation and usage analytics
5
5
  Project-URL: Homepage, https://reqkey.com
6
6
  Project-URL: Documentation, https://reqkey.com/docs
@@ -76,6 +76,9 @@ Description-Content-Type: text/markdown
76
76
  The official Python SDK for API key validation, credit metering, consumer rate
77
77
  limits, and correlated API traffic analytics with ReqKey.
78
78
 
79
+ **Website:** [reqkey.com](https://reqkey.com) ·
80
+ **Documentation:** [reqkey.com/docs](https://reqkey.com/docs)
81
+
79
82
  One distribution contains the shared sync/async client and optional framework
80
83
  adapters. FastAPI, Starlette, Django, Flask, and generic ASGI/WSGI applications
81
84
  can all use ready-made middleware without implementing ReqKey request logic.
@@ -313,7 +316,7 @@ With `mode="both"`:
313
316
  Consumer request
314
317
  → extract consumer key
315
318
  → await /key/validate
316
- → denied: return 401 / 402 / 403 / 429
319
+ → denied: await /ingest, then return 401 / 402 / 403 / 429
317
320
  → approved: run the FastAPI endpoint
318
321
  → collect response metadata
319
322
  → await /ingest with the validation requestId
@@ -328,6 +331,11 @@ starting an unreliable background task:
328
331
  response.
329
332
  - Django's native middleware ingests before returning the response object.
330
333
 
334
+ Denied requests are recorded by default in `mode="both"`, including requests
335
+ with a missing key. This makes invalid-key attempts, exhausted consumers, and
336
+ rate-limit hammering visible in analytics. Set `ingest_denied_requests=False`
337
+ if you deliberately do not want those events or their ingestion cost.
338
+
331
339
  Streaming responses are forwarded chunk by chunk. The middleware keeps only
332
340
  enough data to reconstruct the first 1,000 response-body characters and holds
333
341
  the final ASGI completion message until ingestion finishes. It does not retain
@@ -354,6 +362,10 @@ iterator.
354
362
  | `credits` | `1` | Static cost or sync/async cost resolver. |
355
363
  | `exclude_paths` | empty | Exact paths or trailing-`*` prefix patterns. |
356
364
  | `skip_methods` | `("OPTIONS",)` | Methods that bypass ReqKey. |
365
+ | `consumer_name_resolver` | — | Resolve a display name from a request header, query parameter, or another trusted source. |
366
+ | `client_ip_resolver` | automatic | Override client-IP extraction for a trusted proxy setup. |
367
+ | `on_error` | — | Receive validation or ingestion service failures without exposing request secrets. |
368
+ | `ingest_denied_requests` | `True` | Record denied traffic in `mode="both"`. |
357
369
  | `failure_mode` | `"closed"` | Deny or allow when ReqKey itself is unavailable. |
358
370
  | `error_messages` | built in | Override customer-facing denial messages. |
359
371
  | `timeout` | `2.0` | Timeout for each ReqKey operation. |
@@ -539,6 +551,7 @@ Metadata sent by default:
539
551
  - response status
540
552
  - endpoint processing latency
541
553
  - user agent
554
+ - `consumerName` when a resolver supplies one
542
555
 
543
556
  Additional data is opt-in:
544
557
 
@@ -565,6 +578,60 @@ consumer-key header are always removed from captured headers. Response-body
565
578
  capture is limited to textual content and a hard maximum of 1,000 characters.
566
579
  The limit is enforced again by the HTTP client before `/ingest` is called.
567
580
 
581
+ ### Consumer names
582
+
583
+ Use `consumer_name_resolver` when an upstream gateway or your application
584
+ provides a useful customer label. The resolver may read any part of the
585
+ framework request and may be synchronous or asynchronous in ASGI/Django:
586
+
587
+ ```python
588
+ def consumer_name(request: Request) -> str | None:
589
+ return (
590
+ request.headers.get("X-RapidAPI-User")
591
+ or request.query_params.get("consumerName")
592
+ )
593
+
594
+
595
+ app.add_middleware(
596
+ ReqKeyMiddleware,
597
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
598
+ api_id="api_payments",
599
+ consumer_name_resolver=consumer_name,
600
+ )
601
+ ```
602
+
603
+ The resolved value is sent as analytics metadata. It is not used as the
604
+ consumer API key and does not change authorization.
605
+
606
+ For Django, set `CONSUMER_NAME_RESOLVER` in `REQKEY`. For WSGI, the resolver
607
+ receives the WSGI environment, so a header can be read as
608
+ `environ.get("HTTP_X_RAPIDAPI_USER")`.
609
+
610
+ ### Client IPs and trusted proxies
611
+
612
+ Client-IP capture is opt-in with `capture_client_ip=True`. By default, the SDK
613
+ keeps the framework-provided peer address first: `request.client.host` for
614
+ ASGI, `REMOTE_ADDR` for WSGI, and `request.META["REMOTE_ADDR"]` for Django. It
615
+ only falls back when that value is absent, checking common proxy headers such
616
+ as `X-Forwarded-For`, `X-Real-IP`, Cloudflare, Vercel, Fly, Fastly, Azure,
617
+ App Engine, CloudFront, and standardized `Forwarded` headers.
618
+
619
+ If a trusted proxy always supplies the real client in a specific header, use a
620
+ resolver to override that default explicitly:
621
+
622
+ ```python
623
+ app.add_middleware(
624
+ ReqKeyMiddleware,
625
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
626
+ api_id="api_payments",
627
+ capture_client_ip=True,
628
+ client_ip_resolver=lambda request: request.headers.get("CF-Connecting-IP"),
629
+ )
630
+ ```
631
+
632
+ Only trust forwarding headers when your deployment proxy overwrites them;
633
+ otherwise a caller can spoof their value.
634
+
568
635
  ## Custom denial messages
569
636
 
570
637
  ```python
@@ -616,8 +683,9 @@ The response includes these values when available:
616
683
  - `X-ReqKey-Credits-Remaining`
617
684
  - `X-ReqKey-Validation-Time-Ms`
618
685
 
619
- If browser JavaScript needs these headers, add them to the FastAPI CORS
620
- middleware's `expose_headers` list.
686
+ No CORS configuration is needed for server-to-server clients or for the server
687
+ itself to set these headers. Only cross-origin browser JavaScript that must read
688
+ them needs the names added to the CORS middleware's `expose_headers` list.
621
689
 
622
690
  ## Availability behavior
623
691
 
@@ -636,6 +704,44 @@ but it will not be correlated to a validated consumer.
636
704
  The SDK does not automatically retry `/key/validate`, because validation can
637
705
  deduct credits. Safe automatic retries require server-side idempotency.
638
706
 
707
+ ### Failure alerts
708
+
709
+ Webhook payload formats differ between Discord, Slack, Teams, and custom alert
710
+ receivers, so the SDK does not post one supposed universal webhook format.
711
+ Instead, `on_error` receives a provider-neutral `MiddlewareErrorEvent` for
712
+ ReqKey validation or ingestion failures:
713
+
714
+ ```python
715
+ from reqkey import MiddlewareErrorEvent
716
+
717
+
718
+ async def report_reqkey_failure(event: MiddlewareErrorEvent) -> None:
719
+ await send_to_your_alert_provider(
720
+ {
721
+ "operation": event.operation,
722
+ "message": event.message,
723
+ "method": event.method,
724
+ "path": event.path,
725
+ "request_id": event.request_id,
726
+ "status_code": event.status_code,
727
+ }
728
+ )
729
+
730
+
731
+ app.add_middleware(
732
+ ReqKeyMiddleware,
733
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
734
+ api_id="api_payments",
735
+ on_error=report_reqkey_failure,
736
+ )
737
+ ```
738
+
739
+ The event intentionally excludes API keys, project credentials, headers,
740
+ query parameters, and bodies. Exceptions raised by the callback are logged and
741
+ do not replace the application response. WSGI callbacks are synchronous;
742
+ ASGI callbacks may be synchronous or asynchronous, and Django follows the
743
+ sync/async mode of its middleware chain.
744
+
639
745
  ## Direct sync and async clients
640
746
 
641
747
  Async:
@@ -661,6 +767,19 @@ with ReqKey(project_key=os.environ["REQKEY_PROJECT_KEY"]) as reqkey:
661
767
  decision = reqkey.verify("consumer_key_...", api_id="api_payments")
662
768
  ```
663
769
 
770
+ Direct ingestion also accepts an optional consumer label:
771
+
772
+ ```python
773
+ reqkey.ingest(
774
+ decision.request_id,
775
+ api_id="api_payments",
776
+ method="POST",
777
+ path="/payments",
778
+ status_code=201,
779
+ consumer_name="rapidapi-user",
780
+ )
781
+ ```
782
+
664
783
  ASGI and FastAPI use the asynchronous client and await both network operations.
665
784
  WSGI uses the synchronous client. Django automatically selects the correct
666
785
  client based on its middleware chain, avoiding blocking HTTP calls inside an
@@ -3,6 +3,9 @@
3
3
  The official Python SDK for API key validation, credit metering, consumer rate
4
4
  limits, and correlated API traffic analytics with ReqKey.
5
5
 
6
+ **Website:** [reqkey.com](https://reqkey.com) ·
7
+ **Documentation:** [reqkey.com/docs](https://reqkey.com/docs)
8
+
6
9
  One distribution contains the shared sync/async client and optional framework
7
10
  adapters. FastAPI, Starlette, Django, Flask, and generic ASGI/WSGI applications
8
11
  can all use ready-made middleware without implementing ReqKey request logic.
@@ -240,7 +243,7 @@ With `mode="both"`:
240
243
  Consumer request
241
244
  → extract consumer key
242
245
  → await /key/validate
243
- → denied: return 401 / 402 / 403 / 429
246
+ → denied: await /ingest, then return 401 / 402 / 403 / 429
244
247
  → approved: run the FastAPI endpoint
245
248
  → collect response metadata
246
249
  → await /ingest with the validation requestId
@@ -255,6 +258,11 @@ starting an unreliable background task:
255
258
  response.
256
259
  - Django's native middleware ingests before returning the response object.
257
260
 
261
+ Denied requests are recorded by default in `mode="both"`, including requests
262
+ with a missing key. This makes invalid-key attempts, exhausted consumers, and
263
+ rate-limit hammering visible in analytics. Set `ingest_denied_requests=False`
264
+ if you deliberately do not want those events or their ingestion cost.
265
+
258
266
  Streaming responses are forwarded chunk by chunk. The middleware keeps only
259
267
  enough data to reconstruct the first 1,000 response-body characters and holds
260
268
  the final ASGI completion message until ingestion finishes. It does not retain
@@ -281,6 +289,10 @@ iterator.
281
289
  | `credits` | `1` | Static cost or sync/async cost resolver. |
282
290
  | `exclude_paths` | empty | Exact paths or trailing-`*` prefix patterns. |
283
291
  | `skip_methods` | `("OPTIONS",)` | Methods that bypass ReqKey. |
292
+ | `consumer_name_resolver` | — | Resolve a display name from a request header, query parameter, or another trusted source. |
293
+ | `client_ip_resolver` | automatic | Override client-IP extraction for a trusted proxy setup. |
294
+ | `on_error` | — | Receive validation or ingestion service failures without exposing request secrets. |
295
+ | `ingest_denied_requests` | `True` | Record denied traffic in `mode="both"`. |
284
296
  | `failure_mode` | `"closed"` | Deny or allow when ReqKey itself is unavailable. |
285
297
  | `error_messages` | built in | Override customer-facing denial messages. |
286
298
  | `timeout` | `2.0` | Timeout for each ReqKey operation. |
@@ -466,6 +478,7 @@ Metadata sent by default:
466
478
  - response status
467
479
  - endpoint processing latency
468
480
  - user agent
481
+ - `consumerName` when a resolver supplies one
469
482
 
470
483
  Additional data is opt-in:
471
484
 
@@ -492,6 +505,60 @@ consumer-key header are always removed from captured headers. Response-body
492
505
  capture is limited to textual content and a hard maximum of 1,000 characters.
493
506
  The limit is enforced again by the HTTP client before `/ingest` is called.
494
507
 
508
+ ### Consumer names
509
+
510
+ Use `consumer_name_resolver` when an upstream gateway or your application
511
+ provides a useful customer label. The resolver may read any part of the
512
+ framework request and may be synchronous or asynchronous in ASGI/Django:
513
+
514
+ ```python
515
+ def consumer_name(request: Request) -> str | None:
516
+ return (
517
+ request.headers.get("X-RapidAPI-User")
518
+ or request.query_params.get("consumerName")
519
+ )
520
+
521
+
522
+ app.add_middleware(
523
+ ReqKeyMiddleware,
524
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
525
+ api_id="api_payments",
526
+ consumer_name_resolver=consumer_name,
527
+ )
528
+ ```
529
+
530
+ The resolved value is sent as analytics metadata. It is not used as the
531
+ consumer API key and does not change authorization.
532
+
533
+ For Django, set `CONSUMER_NAME_RESOLVER` in `REQKEY`. For WSGI, the resolver
534
+ receives the WSGI environment, so a header can be read as
535
+ `environ.get("HTTP_X_RAPIDAPI_USER")`.
536
+
537
+ ### Client IPs and trusted proxies
538
+
539
+ Client-IP capture is opt-in with `capture_client_ip=True`. By default, the SDK
540
+ keeps the framework-provided peer address first: `request.client.host` for
541
+ ASGI, `REMOTE_ADDR` for WSGI, and `request.META["REMOTE_ADDR"]` for Django. It
542
+ only falls back when that value is absent, checking common proxy headers such
543
+ as `X-Forwarded-For`, `X-Real-IP`, Cloudflare, Vercel, Fly, Fastly, Azure,
544
+ App Engine, CloudFront, and standardized `Forwarded` headers.
545
+
546
+ If a trusted proxy always supplies the real client in a specific header, use a
547
+ resolver to override that default explicitly:
548
+
549
+ ```python
550
+ app.add_middleware(
551
+ ReqKeyMiddleware,
552
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
553
+ api_id="api_payments",
554
+ capture_client_ip=True,
555
+ client_ip_resolver=lambda request: request.headers.get("CF-Connecting-IP"),
556
+ )
557
+ ```
558
+
559
+ Only trust forwarding headers when your deployment proxy overwrites them;
560
+ otherwise a caller can spoof their value.
561
+
495
562
  ## Custom denial messages
496
563
 
497
564
  ```python
@@ -543,8 +610,9 @@ The response includes these values when available:
543
610
  - `X-ReqKey-Credits-Remaining`
544
611
  - `X-ReqKey-Validation-Time-Ms`
545
612
 
546
- If browser JavaScript needs these headers, add them to the FastAPI CORS
547
- middleware's `expose_headers` list.
613
+ No CORS configuration is needed for server-to-server clients or for the server
614
+ itself to set these headers. Only cross-origin browser JavaScript that must read
615
+ them needs the names added to the CORS middleware's `expose_headers` list.
548
616
 
549
617
  ## Availability behavior
550
618
 
@@ -563,6 +631,44 @@ but it will not be correlated to a validated consumer.
563
631
  The SDK does not automatically retry `/key/validate`, because validation can
564
632
  deduct credits. Safe automatic retries require server-side idempotency.
565
633
 
634
+ ### Failure alerts
635
+
636
+ Webhook payload formats differ between Discord, Slack, Teams, and custom alert
637
+ receivers, so the SDK does not post one supposed universal webhook format.
638
+ Instead, `on_error` receives a provider-neutral `MiddlewareErrorEvent` for
639
+ ReqKey validation or ingestion failures:
640
+
641
+ ```python
642
+ from reqkey import MiddlewareErrorEvent
643
+
644
+
645
+ async def report_reqkey_failure(event: MiddlewareErrorEvent) -> None:
646
+ await send_to_your_alert_provider(
647
+ {
648
+ "operation": event.operation,
649
+ "message": event.message,
650
+ "method": event.method,
651
+ "path": event.path,
652
+ "request_id": event.request_id,
653
+ "status_code": event.status_code,
654
+ }
655
+ )
656
+
657
+
658
+ app.add_middleware(
659
+ ReqKeyMiddleware,
660
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
661
+ api_id="api_payments",
662
+ on_error=report_reqkey_failure,
663
+ )
664
+ ```
665
+
666
+ The event intentionally excludes API keys, project credentials, headers,
667
+ query parameters, and bodies. Exceptions raised by the callback are logged and
668
+ do not replace the application response. WSGI callbacks are synchronous;
669
+ ASGI callbacks may be synchronous or asynchronous, and Django follows the
670
+ sync/async mode of its middleware chain.
671
+
566
672
  ## Direct sync and async clients
567
673
 
568
674
  Async:
@@ -588,6 +694,19 @@ with ReqKey(project_key=os.environ["REQKEY_PROJECT_KEY"]) as reqkey:
588
694
  decision = reqkey.verify("consumer_key_...", api_id="api_payments")
589
695
  ```
590
696
 
697
+ Direct ingestion also accepts an optional consumer label:
698
+
699
+ ```python
700
+ reqkey.ingest(
701
+ decision.request_id,
702
+ api_id="api_payments",
703
+ method="POST",
704
+ path="/payments",
705
+ status_code=201,
706
+ consumer_name="rapidapi-user",
707
+ )
708
+ ```
709
+
591
710
  ASGI and FastAPI use the asynchronous client and await both network operations.
592
711
  WSGI uses the synchronous client. Django automatically selects the correct
593
712
  client based on its middleware chain, avoiding blocking HTTP calls inside an
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reqkey"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "The official Python SDK for ReqKey API key validation and usage analytics"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,12 +9,13 @@ from .exceptions import (
9
9
  ReqKeyTimeoutError,
10
10
  ReqKeyTransportError,
11
11
  )
12
- from .models import VerificationReason, VerificationResult
12
+ from .models import MiddlewareErrorEvent, VerificationReason, VerificationResult
13
13
 
14
- __version__ = "0.1.0"
14
+ __version__ = "0.2.0"
15
15
 
16
16
  __all__ = [
17
17
  "AsyncReqKey",
18
+ "MiddlewareErrorEvent",
18
19
  "ReqKey",
19
20
  "ReqKeyAPIError",
20
21
  "ReqKeyAuthenticationError",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ipaddress
5
6
  from collections.abc import Iterable, Mapping
6
7
  from typing import Literal
7
8
 
@@ -32,6 +33,20 @@ DEFAULT_EXCLUDED_HEADERS = frozenset(
32
33
  }
33
34
  )
34
35
 
36
+ PROXY_CLIENT_IP_HEADERS = (
37
+ "x-forwarded-for",
38
+ "x-real-ip",
39
+ "cf-connecting-ip",
40
+ "true-client-ip",
41
+ "x-vercel-forwarded-for",
42
+ "fly-client-ip",
43
+ "fastly-client-ip",
44
+ "x-azure-clientip",
45
+ "x-appengine-user-ip",
46
+ "cloudfront-viewer-address",
47
+ "forwarded",
48
+ )
49
+
35
50
 
36
51
  def validate_middleware_options(
37
52
  *,
@@ -130,3 +145,54 @@ def filtered_headers(
130
145
  for key, value in headers.items()
131
146
  if key.lower() not in excluded
132
147
  }
148
+
149
+
150
+ def client_ip_from_headers(headers: Mapping[str, str]) -> str | None:
151
+ """Return the first valid IP from common proxy headers.
152
+
153
+ Middleware adapters use this only when the framework did not provide a
154
+ client address. Applications that intentionally trust a proxy header can
155
+ override the behavior with ``client_ip_resolver``.
156
+ """
157
+
158
+ normalized = {key.lower(): value for key, value in headers.items()}
159
+ for name in PROXY_CLIENT_IP_HEADERS:
160
+ value = normalized.get(name)
161
+ if not value:
162
+ continue
163
+ candidates = _forwarded_candidates(value) if name == "forwarded" else value.split(",")
164
+ for candidate in candidates:
165
+ parsed = _parse_ip(candidate)
166
+ if parsed is not None:
167
+ return parsed
168
+ return None
169
+
170
+
171
+ def _forwarded_candidates(value: str) -> list[str]:
172
+ candidates: list[str] = []
173
+ for element in value.split(","):
174
+ for parameter in element.split(";"):
175
+ name, separator, raw = parameter.strip().partition("=")
176
+ if separator and name.lower() == "for":
177
+ candidates.append(raw)
178
+ return candidates
179
+
180
+
181
+ def _parse_ip(value: str) -> str | None:
182
+ candidate = value.strip().strip('"')
183
+ if not candidate or candidate.lower() == "unknown" or candidate.startswith("_"):
184
+ return None
185
+ if candidate.startswith("["):
186
+ end = candidate.find("]")
187
+ if end > 0:
188
+ candidate = candidate[1:end]
189
+ else:
190
+ try:
191
+ return str(ipaddress.ip_address(candidate))
192
+ except ValueError:
193
+ if candidate.count(":") == 1:
194
+ candidate = candidate.partition(":")[0]
195
+ try:
196
+ return str(ipaddress.ip_address(candidate))
197
+ except ValueError:
198
+ return None