scrapely-client-python 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.

Potentially problematic release.


This version of scrapely-client-python might be problematic. Click here for more details.

Files changed (65) hide show
  1. scrapely_client/__init__.py +30 -0
  2. scrapely_client/_charging.py +186 -0
  3. scrapely_client/_client_registry.py +136 -0
  4. scrapely_client/_consts.py +96 -0
  5. scrapely_client/_docs.py +37 -0
  6. scrapely_client/_literals.py +539 -0
  7. scrapely_client/_logging.py +180 -0
  8. scrapely_client/_models.py +4172 -0
  9. scrapely_client/_pagination.py +220 -0
  10. scrapely_client/_resource_clients/__init__.py +84 -0
  11. scrapely_client/_resource_clients/_resource_client.py +567 -0
  12. scrapely_client/_resource_clients/actor.py +1042 -0
  13. scrapely_client/_resource_clients/actor_collection.py +377 -0
  14. scrapely_client/_resource_clients/actor_env_var.py +154 -0
  15. scrapely_client/_resource_clients/actor_env_var_collection.py +165 -0
  16. scrapely_client/_resource_clients/actor_version.py +254 -0
  17. scrapely_client/_resource_clients/actor_version_collection.py +232 -0
  18. scrapely_client/_resource_clients/build.py +244 -0
  19. scrapely_client/_resource_clients/build_collection.py +164 -0
  20. scrapely_client/_resource_clients/dataset.py +1151 -0
  21. scrapely_client/_resource_clients/dataset_collection.py +244 -0
  22. scrapely_client/_resource_clients/key_value_store.py +906 -0
  23. scrapely_client/_resource_clients/key_value_store_collection.py +243 -0
  24. scrapely_client/_resource_clients/log.py +225 -0
  25. scrapely_client/_resource_clients/request_queue.py +1167 -0
  26. scrapely_client/_resource_clients/request_queue_collection.py +240 -0
  27. scrapely_client/_resource_clients/run.py +875 -0
  28. scrapely_client/_resource_clients/run_collection.py +226 -0
  29. scrapely_client/_resource_clients/schedule.py +243 -0
  30. scrapely_client/_resource_clients/schedule_collection.py +260 -0
  31. scrapely_client/_resource_clients/store_collection.py +242 -0
  32. scrapely_client/_resource_clients/task.py +793 -0
  33. scrapely_client/_resource_clients/task_collection.py +322 -0
  34. scrapely_client/_resource_clients/user.py +262 -0
  35. scrapely_client/_resource_clients/webhook.py +292 -0
  36. scrapely_client/_resource_clients/webhook_collection.py +281 -0
  37. scrapely_client/_resource_clients/webhook_dispatch.py +84 -0
  38. scrapely_client/_resource_clients/webhook_dispatch_collection.py +158 -0
  39. scrapely_client/_scrapely_client.py +837 -0
  40. scrapely_client/_statistics.py +29 -0
  41. scrapely_client/_status_message_watcher.py +197 -0
  42. scrapely_client/_streamed_log.py +250 -0
  43. scrapely_client/_typeddicts.py +408 -0
  44. scrapely_client/_utils/__init__.py +0 -0
  45. scrapely_client/_utils/crypto.py +87 -0
  46. scrapely_client/_utils/encoding.py +133 -0
  47. scrapely_client/_utils/errors.py +33 -0
  48. scrapely_client/_utils/http.py +137 -0
  49. scrapely_client/_utils/time.py +32 -0
  50. scrapely_client/_utils/try_import.py +61 -0
  51. scrapely_client/errors.py +190 -0
  52. scrapely_client/http_clients/__init__.py +10 -0
  53. scrapely_client/http_clients/_base.py +430 -0
  54. scrapely_client/http_clients/_impit.py +598 -0
  55. scrapely_client/http_compressors/__init__.py +18 -0
  56. scrapely_client/http_compressors/_base.py +25 -0
  57. scrapely_client/http_compressors/_brotli.py +38 -0
  58. scrapely_client/http_compressors/_gzip.py +38 -0
  59. scrapely_client/http_compressors/_resolve.py +38 -0
  60. scrapely_client/py.typed +0 -0
  61. scrapely_client/types.py +52 -0
  62. scrapely_client_python-1.0.0.dist-info/METADATA +244 -0
  63. scrapely_client_python-1.0.0.dist-info/RECORD +65 -0
  64. scrapely_client_python-1.0.0.dist-info/WHEEL +4 -0
  65. scrapely_client_python-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,30 @@
1
+ from importlib import metadata
2
+
3
+ from ._charging import (
4
+ USES_PUSH_DATA_INTERCEPTION,
5
+ ChargeResult,
6
+ ChargingHooks,
7
+ PpeAwarePushDataContext,
8
+ normalize_items,
9
+ push_data_charging_context,
10
+ )
11
+ from ._scrapely_client import ApifyClient, ApifyClientAsync, ScrapelyClient, ScrapelyClientAsync
12
+
13
+ try:
14
+ __version__ = metadata.version('scrapely-client-python')
15
+ except metadata.PackageNotFoundError:
16
+ __version__ = '0.0.0'
17
+
18
+ __all__ = [
19
+ 'USES_PUSH_DATA_INTERCEPTION',
20
+ 'ApifyClient',
21
+ 'ApifyClientAsync',
22
+ 'ChargeResult',
23
+ 'ChargingHooks',
24
+ 'PpeAwarePushDataContext',
25
+ 'ScrapelyClient',
26
+ 'ScrapelyClientAsync',
27
+ '__version__',
28
+ 'normalize_items',
29
+ 'push_data_charging_context',
30
+ ]
@@ -0,0 +1,186 @@
1
+ """Opt-in pay-per-event charging interception for dataset pushes.
2
+
3
+ Port of ``scrapely-python``'s ``patched_apify_client.py`` semantics into the client
4
+ itself, with the charging logic supplied by the host SDK via the
5
+ :class:`ChargingHooks` protocol (no SDK import — the SDK adapts its
6
+ ``ChargingManager`` to these hooks later).
7
+
8
+ Without hooks (the default), the client behaves exactly like stock
9
+ ``apify-client``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import inspect
15
+ import json
16
+ from contextvars import ContextVar
17
+ from dataclasses import dataclass, field
18
+ from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Awaitable, Callable, Sequence
22
+
23
+ USES_PUSH_DATA_INTERCEPTION = '__scrapely_uses_push_data_interception__'
24
+ """Attribute marking dataset clients whose ``push_items`` charges pay-per-event items.
25
+
26
+ The host SDK checks this attribute to detect that push-data interception is active."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ChargeResult:
31
+ """Result of a charged push-data operation.
32
+
33
+ Mirrors the SDK-side ``ChargeResult`` contract; the client only stores and merges
34
+ these values, it never computes them.
35
+ """
36
+
37
+ event_charge_limit_reached: bool
38
+ """Whether an event charge limit was reached during the operation."""
39
+
40
+ charged_count: int
41
+ """Total number of charged items."""
42
+
43
+ chargeable_within_limit: dict[str, int | None] = field(default_factory=dict)
44
+ """How many more items could be charged per event within the current limits."""
45
+
46
+
47
+ @dataclass
48
+ class PpeAwarePushDataContext:
49
+ """Context carried through ``push_data_charging_context``.
50
+
51
+ Wrapping a push in ``push_data_charging_context.run({...}, fn)`` makes every
52
+ intercepted ``push_items()`` inside ``fn`` charge ``event_name`` and merge its
53
+ result into the context.
54
+ """
55
+
56
+ event_name: str | None = None
57
+ charge_result: ChargeResult | None = None
58
+
59
+
60
+ push_data_charging_context: ContextVar[PpeAwarePushDataContext | None] = ContextVar(
61
+ 'scrapely_push_data_charging_context', default=None
62
+ )
63
+ """ContextVar for the current :class:`PpeAwarePushDataContext` (async-local in asyncio)."""
64
+
65
+
66
+ @runtime_checkable
67
+ class ChargingHooks(Protocol):
68
+ """Charging hooks supplied by the host SDK (e.g. ``scrapely``'s ``ChargingManager``).
69
+
70
+ The client knows nothing about pricing, budgets or ledgers — it only routes
71
+ ``push_items()`` through these hooks when they are configured and the dataset
72
+ is the actor run's default dataset.
73
+ """
74
+
75
+ def should_charge_dataset_pushes(self, dataset_id: str) -> bool:
76
+ """Whether pushes to this dataset should be intercepted and charged."""
77
+ ...
78
+
79
+ def push_and_charge(
80
+ self,
81
+ *,
82
+ items: Sequence[dict[str, Any]],
83
+ event_name: str | None,
84
+ push_fn: Callable[[Sequence[dict[str, Any]]], Any],
85
+ ) -> ChargeResult | Awaitable[ChargeResult]:
86
+ """Charge for (and persist) the given items.
87
+
88
+ ``push_fn`` persists the budget-limited subset of items; implementations must
89
+ call it with only the items that were actually accepted for charging. Both
90
+ sync and async implementations are supported (async clients await awaitables).
91
+ """
92
+ ...
93
+
94
+ def merge_charge_results(self, a: ChargeResult, b: ChargeResult) -> ChargeResult:
95
+ """Merge two charge results (e.g. multiple pushes within one context)."""
96
+ ...
97
+
98
+
99
+ def normalize_items(items: Any) -> list[dict[str, Any]]:
100
+ """Normalize ``push_items`` input (str, dict, or list of either) to a list of dicts."""
101
+ if isinstance(items, str):
102
+ parsed = json.loads(items)
103
+ return parsed if isinstance(parsed, list) else [parsed]
104
+ if isinstance(items, list):
105
+ result: list[dict[str, Any]] = []
106
+ for item in items:
107
+ if isinstance(item, str):
108
+ parsed = json.loads(item)
109
+ if isinstance(parsed, list):
110
+ result.extend(parsed)
111
+ else:
112
+ result.append(parsed)
113
+ else:
114
+ result.append(item)
115
+ return result
116
+ return [items]
117
+
118
+
119
+ def _merge_context_charge_result(context: PpeAwarePushDataContext, hooks: ChargingHooks, result: ChargeResult) -> None:
120
+ if context.charge_result is None:
121
+ context.charge_result = result
122
+ else:
123
+ context.charge_result = hooks.merge_charge_results(context.charge_result, result)
124
+
125
+
126
+ def wrap_dataset_client_with_charging(client: Any, hooks: ChargingHooks, *, is_async: bool) -> Any:
127
+ """Wrap a dataset client's ``push_items`` with charging interception (method swap by design).
128
+
129
+ Semantics match ``scrapely``'s ``patched_apify_client.py``: normalize items →
130
+ ``push_and_charge`` with a ``push_fn`` that pushes the accepted subset via the
131
+ original ``push_items``, and accumulate the charge result into the ambient
132
+ ``push_data_charging_context`` when present.
133
+ """
134
+ original_push_items = client.push_items
135
+
136
+ if is_async:
137
+
138
+ async def patched_push_items(items: Any, **kwargs: Any) -> None:
139
+ context = push_data_charging_context.get()
140
+ normalized = normalize_items(items)
141
+
142
+ async def push_fn(limited_items: Sequence[dict[str, Any]]) -> None:
143
+ await original_push_items(limited_items, **kwargs)
144
+
145
+ outcome = hooks.push_and_charge(
146
+ items=normalized, event_name=context.event_name if context else None, push_fn=push_fn
147
+ )
148
+ if inspect.isawaitable(outcome):
149
+ outcome = await outcome
150
+ result = cast('ChargeResult', outcome)
151
+ if context is not None:
152
+ _merge_context_charge_result(context, hooks, result)
153
+
154
+ else:
155
+
156
+ def patched_push_items(items: Any, **kwargs: Any) -> None:
157
+ context = push_data_charging_context.get()
158
+ normalized = normalize_items(items)
159
+
160
+ def push_fn(limited_items: Sequence[dict[str, Any]]) -> None:
161
+ original_push_items(limited_items, **kwargs)
162
+
163
+ outcome = hooks.push_and_charge(
164
+ items=normalized, event_name=context.event_name if context else None, push_fn=push_fn
165
+ )
166
+ result = cast('ChargeResult', outcome)
167
+ if inspect.isawaitable(outcome):
168
+ msg = 'Sync clients require synchronous ChargingHooks.push_and_charge'
169
+ raise TypeError(msg)
170
+ if context is not None:
171
+ _merge_context_charge_result(context, hooks, result)
172
+
173
+ client.push_items = patched_push_items # type: ignore[method-assign]
174
+ setattr(client, USES_PUSH_DATA_INTERCEPTION, True)
175
+ return client
176
+
177
+
178
+ __all__ = [
179
+ 'USES_PUSH_DATA_INTERCEPTION',
180
+ 'ChargeResult',
181
+ 'ChargingHooks',
182
+ 'PpeAwarePushDataContext',
183
+ 'normalize_items',
184
+ 'push_data_charging_context',
185
+ 'wrap_dataset_client_with_charging',
186
+ ]
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from scrapely_client._resource_clients import (
8
+ ActorClient,
9
+ ActorClientAsync,
10
+ ActorCollectionClient,
11
+ ActorCollectionClientAsync,
12
+ ActorEnvVarClient,
13
+ ActorEnvVarClientAsync,
14
+ ActorEnvVarCollectionClient,
15
+ ActorEnvVarCollectionClientAsync,
16
+ ActorVersionClient,
17
+ ActorVersionClientAsync,
18
+ ActorVersionCollectionClient,
19
+ ActorVersionCollectionClientAsync,
20
+ BuildClient,
21
+ BuildClientAsync,
22
+ BuildCollectionClient,
23
+ BuildCollectionClientAsync,
24
+ DatasetClient,
25
+ DatasetClientAsync,
26
+ DatasetCollectionClient,
27
+ DatasetCollectionClientAsync,
28
+ KeyValueStoreClient,
29
+ KeyValueStoreClientAsync,
30
+ KeyValueStoreCollectionClient,
31
+ KeyValueStoreCollectionClientAsync,
32
+ LogClient,
33
+ LogClientAsync,
34
+ RequestQueueClient,
35
+ RequestQueueClientAsync,
36
+ RequestQueueCollectionClient,
37
+ RequestQueueCollectionClientAsync,
38
+ RunClient,
39
+ RunClientAsync,
40
+ RunCollectionClient,
41
+ RunCollectionClientAsync,
42
+ ScheduleClient,
43
+ ScheduleClientAsync,
44
+ ScheduleCollectionClient,
45
+ ScheduleCollectionClientAsync,
46
+ StoreCollectionClient,
47
+ StoreCollectionClientAsync,
48
+ TaskClient,
49
+ TaskClientAsync,
50
+ TaskCollectionClient,
51
+ TaskCollectionClientAsync,
52
+ UserClient,
53
+ UserClientAsync,
54
+ WebhookClient,
55
+ WebhookClientAsync,
56
+ WebhookCollectionClient,
57
+ WebhookCollectionClientAsync,
58
+ WebhookDispatchClient,
59
+ WebhookDispatchClientAsync,
60
+ WebhookDispatchCollectionClient,
61
+ WebhookDispatchCollectionClientAsync,
62
+ )
63
+
64
+
65
+ @dataclass
66
+ class ClientRegistry:
67
+ """Bundle of all sync client classes for dependency injection.
68
+
69
+ This config object is passed to the resource clients to avoid circular dependencies. Each resource client
70
+ receives this config and can instantiate other clients as needed.
71
+ """
72
+
73
+ actor_client: type[ActorClient]
74
+ actor_collection_client: type[ActorCollectionClient]
75
+ actor_env_var_client: type[ActorEnvVarClient]
76
+ actor_env_var_collection_client: type[ActorEnvVarCollectionClient]
77
+ actor_version_client: type[ActorVersionClient]
78
+ actor_version_collection_client: type[ActorVersionCollectionClient]
79
+ build_client: type[BuildClient]
80
+ build_collection_client: type[BuildCollectionClient]
81
+ dataset_client: type[DatasetClient]
82
+ dataset_collection_client: type[DatasetCollectionClient]
83
+ key_value_store_client: type[KeyValueStoreClient]
84
+ key_value_store_collection_client: type[KeyValueStoreCollectionClient]
85
+ log_client: type[LogClient]
86
+ request_queue_client: type[RequestQueueClient]
87
+ request_queue_collection_client: type[RequestQueueCollectionClient]
88
+ run_client: type[RunClient]
89
+ run_collection_client: type[RunCollectionClient]
90
+ schedule_client: type[ScheduleClient]
91
+ schedule_collection_client: type[ScheduleCollectionClient]
92
+ store_collection_client: type[StoreCollectionClient]
93
+ task_client: type[TaskClient]
94
+ task_collection_client: type[TaskCollectionClient]
95
+ user_client: type[UserClient]
96
+ webhook_client: type[WebhookClient]
97
+ webhook_collection_client: type[WebhookCollectionClient]
98
+ webhook_dispatch_client: type[WebhookDispatchClient]
99
+ webhook_dispatch_collection_client: type[WebhookDispatchCollectionClient]
100
+
101
+
102
+ @dataclass
103
+ class ClientRegistryAsync:
104
+ """Bundle of all async client classes for dependency injection.
105
+
106
+ This config object is passed to the resource clients to avoid circular dependencies. Each resource client
107
+ receives this config and can instantiate other clients as needed.
108
+ """
109
+
110
+ actor_client: type[ActorClientAsync]
111
+ actor_collection_client: type[ActorCollectionClientAsync]
112
+ actor_env_var_client: type[ActorEnvVarClientAsync]
113
+ actor_env_var_collection_client: type[ActorEnvVarCollectionClientAsync]
114
+ actor_version_client: type[ActorVersionClientAsync]
115
+ actor_version_collection_client: type[ActorVersionCollectionClientAsync]
116
+ build_client: type[BuildClientAsync]
117
+ build_collection_client: type[BuildCollectionClientAsync]
118
+ dataset_client: type[DatasetClientAsync]
119
+ dataset_collection_client: type[DatasetCollectionClientAsync]
120
+ key_value_store_client: type[KeyValueStoreClientAsync]
121
+ key_value_store_collection_client: type[KeyValueStoreCollectionClientAsync]
122
+ log_client: type[LogClientAsync]
123
+ request_queue_client: type[RequestQueueClientAsync]
124
+ request_queue_collection_client: type[RequestQueueCollectionClientAsync]
125
+ run_client: type[RunClientAsync]
126
+ run_collection_client: type[RunCollectionClientAsync]
127
+ schedule_client: type[ScheduleClientAsync]
128
+ schedule_collection_client: type[ScheduleCollectionClientAsync]
129
+ store_collection_client: type[StoreCollectionClientAsync]
130
+ task_client: type[TaskClientAsync]
131
+ task_collection_client: type[TaskCollectionClientAsync]
132
+ user_client: type[UserClientAsync]
133
+ webhook_client: type[WebhookClientAsync]
134
+ webhook_collection_client: type[WebhookCollectionClientAsync]
135
+ webhook_dispatch_client: type[WebhookDispatchClientAsync]
136
+ webhook_dispatch_collection_client: type[WebhookDispatchCollectionClientAsync]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ DEFAULT_API_URL = 'https://api-staging.scrape.ly'
6
+ """Default base URL for the Scrapely API."""
7
+
8
+ API_VERSION = 'v2'
9
+ """Current Scrapely API version."""
10
+
11
+ DEFAULT_TIMEOUT_SHORT = timedelta(seconds=5)
12
+ """Default timeout for fast CRUD operations (e.g., get, update, delete)."""
13
+
14
+ DEFAULT_TIMEOUT_MEDIUM = timedelta(seconds=30)
15
+ """Default timeout for batch, list, and data transfer operations."""
16
+
17
+ DEFAULT_TIMEOUT_LONG = timedelta(seconds=360)
18
+ """Default timeout for long-polling, streaming, and other heavy operations."""
19
+
20
+ DEFAULT_TIMEOUT_MAX = timedelta(seconds=360)
21
+ """Default maximum timeout cap for individual API requests (limits exponential growth)."""
22
+
23
+ DEFAULT_MAX_RETRIES = 4
24
+ """Default maximum number of retries for failed requests."""
25
+
26
+ DEFAULT_MIN_DELAY_BETWEEN_RETRIES = timedelta(milliseconds=500)
27
+ """Default minimum delay between retries."""
28
+
29
+ DEFAULT_WAIT_FOR_FINISH = timedelta(seconds=999999)
30
+ """Default maximum wait time for job completion (effectively infinite)."""
31
+
32
+ DEFAULT_WAIT_WHEN_JOB_NOT_EXIST = timedelta(seconds=3)
33
+ """How long to wait for a job to exist before giving up."""
34
+
35
+ OVERRIDABLE_DEFAULT_HEADERS = {'Accept', 'Authorization', 'Accept-Encoding', 'User-Agent'}
36
+ """Headers that can be overridden by users, but will trigger a warning if they do so, as it may lead to API errors."""
37
+
38
+ MIN_COMPRESSION_SIZE = 1024
39
+ """Smallest request body, in bytes, that is worth compressing.
40
+
41
+ A smaller body already fits in a single network packet, so compressing it costs CPU time without
42
+ saving a round trip.
43
+ """
44
+
45
+ ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES = ('audio/', 'image/', 'video/')
46
+ """Media type prefixes whose payloads carry their own compression, so compressing the request body is wasted work."""
47
+
48
+ ALREADY_COMPRESSED_MEDIA_TYPES = frozenset(
49
+ {
50
+ 'application/epub+zip',
51
+ 'application/gzip',
52
+ 'application/java-archive',
53
+ 'application/vnd.android.package-archive',
54
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
55
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
56
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
57
+ 'application/vnd.rar',
58
+ 'application/x-7z-compressed',
59
+ 'application/x-bzip',
60
+ 'application/x-bzip2',
61
+ 'application/x-gzip',
62
+ 'application/x-rar-compressed',
63
+ 'application/x-xz',
64
+ 'application/x-zip-compressed',
65
+ 'application/zip',
66
+ 'application/zstd',
67
+ 'font/woff',
68
+ 'font/woff2',
69
+ }
70
+ )
71
+ """Exact media types whose payloads carry their own compression."""
72
+
73
+ COMPRESSIBLE_MEDIA_TYPES = frozenset(
74
+ {
75
+ 'audio/aiff',
76
+ 'audio/basic',
77
+ 'audio/l16',
78
+ 'audio/l24',
79
+ 'audio/midi',
80
+ 'audio/vnd.wave',
81
+ 'audio/wav',
82
+ 'audio/wave',
83
+ 'audio/x-aiff',
84
+ 'audio/x-wav',
85
+ 'image/bmp',
86
+ 'image/tiff',
87
+ 'image/vnd.adobe.photoshop',
88
+ 'image/vnd.microsoft.icon',
89
+ 'image/x-icon',
90
+ 'image/x-ms-bmp',
91
+ }
92
+ )
93
+ """Uncompressed media types that sit under an already-compressed prefix, so compressing them still pays off."""
94
+
95
+ COMPRESSIBLE_MEDIA_TYPE_SUFFIXES = ('+json', '+xml')
96
+ """Structured syntax suffixes marking a media type as text even under an already-compressed prefix (`image/svg+xml`)."""
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Literal, TypeVar
5
+
6
+ # The order of the rendered API groups is defined by GROUP_ORDER in website/transformDocs.js
7
+ # and applied via groupSort in website/docusaurus.config.js.
8
+ GroupName = Literal[
9
+ 'Scrapely API clients',
10
+ 'HTTP clients',
11
+ 'Resource clients',
12
+ 'Errors',
13
+ 'Models',
14
+ 'Typed dicts',
15
+ 'Other',
16
+ ]
17
+
18
+ T = TypeVar('T', bound=Callable[..., Any])
19
+
20
+
21
+ def docs_group(group_name: GroupName) -> Callable[[T], T]: # noqa: ARG001
22
+ """Mark a symbol for rendering and grouping in documentation.
23
+
24
+ This decorator is used solely for documentation purposes and does not modify the behavior
25
+ of the decorated callable.
26
+
27
+ Args:
28
+ group_name: The documentation group to which the symbol belongs.
29
+
30
+ Returns:
31
+ The original callable without modification.
32
+ """
33
+
34
+ def wrapper(func: T) -> T:
35
+ return func
36
+
37
+ return wrapper