agentscope-runtime 1.0.4__py3-none-any.whl → 1.0.5__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.
Files changed (49) hide show
  1. agentscope_runtime/adapters/agentscope/stream.py +1 -1
  2. agentscope_runtime/adapters/langgraph/stream.py +120 -70
  3. agentscope_runtime/cli/commands/deploy.py +465 -1
  4. agentscope_runtime/cli/commands/stop.py +16 -0
  5. agentscope_runtime/common/container_clients/__init__.py +52 -0
  6. agentscope_runtime/common/container_clients/agentrun_client.py +6 -4
  7. agentscope_runtime/common/container_clients/boxlite_client.py +442 -0
  8. agentscope_runtime/common/container_clients/docker_client.py +0 -20
  9. agentscope_runtime/common/container_clients/fc_client.py +6 -4
  10. agentscope_runtime/common/container_clients/gvisor_client.py +38 -0
  11. agentscope_runtime/common/container_clients/knative_client.py +1 -0
  12. agentscope_runtime/common/utils/deprecation.py +164 -0
  13. agentscope_runtime/engine/app/agent_app.py +16 -4
  14. agentscope_runtime/engine/deployers/__init__.py +31 -20
  15. agentscope_runtime/engine/deployers/adapter/__init__.py +8 -0
  16. agentscope_runtime/engine/deployers/adapter/a2a/a2a_protocol_adapter.py +9 -8
  17. agentscope_runtime/engine/deployers/adapter/a2a/nacos_a2a_registry.py +19 -1
  18. agentscope_runtime/engine/deployers/adapter/agui/__init__.py +8 -0
  19. agentscope_runtime/engine/deployers/adapter/agui/agui_adapter_utils.py +652 -0
  20. agentscope_runtime/engine/deployers/adapter/agui/agui_protocol_adapter.py +225 -0
  21. agentscope_runtime/engine/deployers/pai_deployer.py +2335 -0
  22. agentscope_runtime/engine/deployers/utils/net_utils.py +37 -0
  23. agentscope_runtime/engine/deployers/utils/oss_utils.py +38 -0
  24. agentscope_runtime/engine/deployers/utils/package.py +46 -42
  25. agentscope_runtime/engine/helpers/agent_api_client.py +372 -0
  26. agentscope_runtime/engine/runner.py +1 -0
  27. agentscope_runtime/engine/schemas/agent_schemas.py +9 -3
  28. agentscope_runtime/engine/services/agent_state/__init__.py +7 -0
  29. agentscope_runtime/engine/services/memory/__init__.py +7 -0
  30. agentscope_runtime/engine/services/memory/redis_memory_service.py +15 -16
  31. agentscope_runtime/engine/services/session_history/__init__.py +7 -0
  32. agentscope_runtime/engine/tracing/local_logging_handler.py +2 -3
  33. agentscope_runtime/sandbox/box/sandbox.py +4 -0
  34. agentscope_runtime/sandbox/manager/sandbox_manager.py +11 -25
  35. agentscope_runtime/sandbox/manager/server/config.py +3 -1
  36. agentscope_runtime/sandbox/model/manager_config.py +11 -9
  37. agentscope_runtime/tools/modelstudio_memory/__init__.py +106 -0
  38. agentscope_runtime/tools/modelstudio_memory/base.py +220 -0
  39. agentscope_runtime/tools/modelstudio_memory/config.py +86 -0
  40. agentscope_runtime/tools/modelstudio_memory/core.py +594 -0
  41. agentscope_runtime/tools/modelstudio_memory/exceptions.py +60 -0
  42. agentscope_runtime/tools/modelstudio_memory/schemas.py +253 -0
  43. agentscope_runtime/version.py +1 -1
  44. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/METADATA +101 -62
  45. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/RECORD +49 -34
  46. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/WHEEL +0 -0
  47. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/entry_points.txt +0 -0
  48. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/licenses/LICENSE +0 -0
  49. {agentscope_runtime-1.0.4.dist-info → agentscope_runtime-1.0.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,442 @@
1
+ # -*- coding: utf-8 -*-
2
+ # pylint: disable=too-many-branches
3
+ import atexit
4
+ import logging
5
+ import socket
6
+ import traceback
7
+
8
+ import boxlite
9
+ from boxlite import SyncBoxlite
10
+
11
+ from .base_client import BaseClient
12
+ from ..collections import (
13
+ RedisSetCollection,
14
+ InMemorySetCollection,
15
+ RedisMapping,
16
+ InMemoryMapping,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class BoxliteClient(BaseClient):
23
+ """
24
+ BoxLite client implementation that provides a Docker-like interface
25
+ for managing boxes using the BoxLite SDK.
26
+ """
27
+
28
+ def __init__(self, config=None):
29
+ """
30
+ Initialize the BoxLite client.
31
+
32
+ Args:
33
+ config: Configuration object with optional attributes:
34
+ - port_range: Tuple of (start, end) for port range
35
+ (default: (8000, 9000))
36
+ - redis_enabled: Whether to use Redis for port management
37
+ (default: False)
38
+ - redis_server: Redis server host (default: 'localhost')
39
+ - redis_port: Redis server port (default: 6379)
40
+ - redis_db: Redis database number (default: 0)
41
+ - redis_user: Redis username (optional)
42
+ - redis_password: Redis password (optional)
43
+ - redis_port_key: Redis key prefix for ports
44
+ (default: 'boxlite:ports')
45
+ """
46
+ self.config = config
47
+
48
+ self.port_range = range(*self.config.port_range)
49
+
50
+ # Initialize port management
51
+ if hasattr(self.config, "redis_enabled") and self.config.redis_enabled:
52
+ import redis
53
+
54
+ redis_client = redis.Redis(
55
+ host=getattr(self.config, "redis_server", "localhost"),
56
+ port=getattr(self.config, "redis_port", 6379),
57
+ db=getattr(self.config, "redis_db", 0),
58
+ username=getattr(self.config, "redis_user", None),
59
+ password=getattr(self.config, "redis_password", None),
60
+ decode_responses=True,
61
+ )
62
+ try:
63
+ redis_client.ping()
64
+ except ConnectionError as e:
65
+ raise RuntimeError(
66
+ "Unable to connect to the Redis server.",
67
+ ) from e
68
+
69
+ self.port_set = RedisSetCollection(
70
+ redis_client,
71
+ set_name=getattr(
72
+ self.config,
73
+ "redis_port_key",
74
+ "boxlite:ports",
75
+ ),
76
+ )
77
+ self.ports_cache = RedisMapping(
78
+ redis_client,
79
+ prefix=getattr(self.config, "redis_port_key", "boxlite:ports"),
80
+ )
81
+ else:
82
+ # Use in-memory collections
83
+ self.port_set = InMemorySetCollection()
84
+ self.ports_cache = InMemoryMapping()
85
+
86
+ # Initialize BoxLite runtime
87
+ try:
88
+ from ...sandbox.constant import REGISTRY
89
+
90
+ if REGISTRY:
91
+ image_registries = [REGISTRY]
92
+ else:
93
+ image_registries = ["ghcr.io", "docker.io"]
94
+
95
+ options = boxlite.Options(
96
+ image_registries=image_registries,
97
+ )
98
+ self.runtime = SyncBoxlite(options=options)
99
+ self.runtime.start()
100
+ except Exception as e:
101
+ raise RuntimeError(
102
+ f"BoxLite client initialization failed: {str(e)}\n"
103
+ "Solutions:\n"
104
+ "• Ensure BoxLite is properly installed\n"
105
+ "• Check BoxLite runtime configuration",
106
+ ) from e
107
+
108
+ atexit.register(self._cleanup_runtime)
109
+
110
+ def _cleanup_runtime(self):
111
+ try:
112
+ if hasattr(self, "runtime") and self.runtime:
113
+ if hasattr(self.runtime, "__exit__"):
114
+ self.runtime.__exit__(None, None, None)
115
+ elif hasattr(self.runtime, "close"):
116
+ self.runtime.stop()
117
+ logger.info("BoxLite runtime cleaned up via atexit.")
118
+ except Exception as e:
119
+ logger.warning(f"An error occurred during BoxLite cleanup: {e}")
120
+ logger.debug(traceback.format_exc())
121
+
122
+ def create(
123
+ self,
124
+ image,
125
+ name=None,
126
+ ports=None,
127
+ volumes=None,
128
+ environment=None,
129
+ runtime_config=None,
130
+ ):
131
+ """
132
+ Create a new BoxLite box.
133
+
134
+ Args:
135
+ image: Container image to use
136
+ name: Optional name for the box
137
+ ports: List of container ports to expose (e.g., [8080, 3000])
138
+ volumes: List of volume mounts as
139
+ (host_path, guest_path, mode) tuples
140
+ environment: Dict of environment variables
141
+ runtime_config: Additional runtime configuration
142
+ (cpus, memory_mib, etc.)
143
+
144
+ Returns:
145
+ Tuple of (container_id, host_ports, host) or (None, None,
146
+ None) on failure
147
+ """
148
+ if runtime_config is None:
149
+ runtime_config = {}
150
+
151
+ port_mapping = {}
152
+
153
+ if ports:
154
+ free_ports = self._find_free_ports(len(ports))
155
+ for container_port, host_port in zip(ports, free_ports):
156
+ port_mapping[container_port] = host_port
157
+
158
+ try:
159
+ # Convert environment dict to list of tuples
160
+ env_list = []
161
+ if environment:
162
+ env_list = list(environment.items())
163
+
164
+ # Convert volumes to BoxLite format
165
+ volume_list = []
166
+ if volumes:
167
+ for vol in volumes:
168
+ if isinstance(vol, (list, tuple)) and len(vol) >= 2:
169
+ host_path = vol[0]
170
+ guest_path = vol[1]
171
+ read_only = len(vol) > 2 and vol[2] in (
172
+ "ro",
173
+ "readonly",
174
+ True,
175
+ )
176
+ volume_list.append(
177
+ (
178
+ host_path,
179
+ guest_path,
180
+ "ro" if read_only else "rw",
181
+ ),
182
+ )
183
+
184
+ # Convert ports to BoxLite format
185
+ port_list = []
186
+ for container_port, host_port in port_mapping.items():
187
+ if isinstance(container_port, str):
188
+ if "/" in container_port:
189
+ container_port = container_port.split("/")[0]
190
+ port_list.append((int(host_port), int(container_port), "tcp"))
191
+
192
+ # Create BoxOptions
193
+ box_options = boxlite.BoxOptions(
194
+ image=image,
195
+ env=env_list,
196
+ volumes=volume_list,
197
+ ports=port_list,
198
+ auto_remove=False, # We'll manage removal ourselves
199
+ detach=True,
200
+ **runtime_config,
201
+ )
202
+
203
+ # Create the box
204
+ box = self.runtime.create(box_options, name=name)
205
+ box_id = box.id
206
+
207
+ logger.debug(f"✓ Box created: {box.id}")
208
+
209
+ # Execute command (mirrors: await box.exec())
210
+ execution = box.exec("echo", ["Hello from default runtime"])
211
+ stdout = execution.stdout()
212
+
213
+ logger.debug("Output:")
214
+ for line in stdout: # Regular for loop, not async for
215
+ logger.debug(f" {line.strip()}")
216
+
217
+ exec_result = execution.wait() # No await
218
+ logger.debug(f"✓ Exit code: {exec_result.exit_code}")
219
+
220
+ # Store port mapping
221
+ if port_mapping:
222
+ self.ports_cache.set(box_id, list(port_mapping.values()))
223
+
224
+ return box_id, list(port_mapping.values()), "localhost"
225
+ except Exception as e:
226
+ logger.warning(f"An error occurred: {e}")
227
+ logger.debug(f"{traceback.format_exc()}")
228
+ return None, None, None
229
+
230
+ def start(self, container_id):
231
+ """
232
+ Start a BoxLite box.
233
+
234
+ Args:
235
+ container_id: Box ID or name
236
+
237
+ Returns:
238
+ bool: True if successful, False otherwise
239
+ """
240
+ try:
241
+ box = self.runtime.get(container_id)
242
+ if box is None:
243
+ logger.warning(f"Box '{container_id}' not found")
244
+ return False
245
+
246
+ box.start()
247
+ return True
248
+ except Exception as e:
249
+ logger.warning(f"An error occurred: {e}")
250
+ logger.debug(f"{traceback.format_exc()}")
251
+ return False
252
+
253
+ def stop(self, container_id, timeout=None):
254
+ """
255
+ Stop a BoxLite box.
256
+
257
+ Args:
258
+ container_id: Box ID or name
259
+ timeout: Optional timeout in seconds (not used in BoxLite)
260
+
261
+ Returns:
262
+ bool: True if successful, False otherwise
263
+ """
264
+ try:
265
+ box = self.runtime.get(container_id)
266
+ if box is None:
267
+ logger.warning(f"Box '{container_id}' not found")
268
+ return False
269
+
270
+ # Stop the box
271
+ box.stop()
272
+ return True
273
+ except Exception as e:
274
+ logger.warning(f"An error occurred: {e}")
275
+ logger.debug(f"{traceback.format_exc()}")
276
+ return False
277
+
278
+ def remove(self, container_id, force=False):
279
+ """
280
+ Remove a BoxLite box.
281
+
282
+ Args:
283
+ container_id: Box ID or name
284
+ force: If True, stop the box first if running
285
+
286
+ Returns:
287
+ bool: True if successful, False otherwise
288
+ """
289
+ try:
290
+ # Get ports before removal
291
+ ports = self.ports_cache.get(container_id)
292
+
293
+ # Remove the box
294
+ self.runtime.remove(container_id, force=force)
295
+
296
+ # Clean up port cache
297
+ self.ports_cache.delete(container_id)
298
+
299
+ # Remove ports from port set
300
+ if ports:
301
+ for host_port in ports:
302
+ self.port_set.remove(host_port)
303
+
304
+ return True
305
+ except Exception as e:
306
+ logger.warning(f"An error occurred: {e}")
307
+ logger.debug(f"{traceback.format_exc()}")
308
+ return False
309
+
310
+ def inspect(self, container_id):
311
+ """
312
+ Inspect a BoxLite box.
313
+
314
+ Args:
315
+ container_id: Box ID or name
316
+
317
+ Returns:
318
+ Dict with box information or None if not found
319
+ """
320
+ try:
321
+ box = self.runtime.get(container_id)
322
+
323
+ if box is None:
324
+ return None
325
+
326
+ info = box.info()
327
+ ports = self.ports_cache.get(container_id) or []
328
+
329
+ # Convert BoxInfo to dict format similar to Docker
330
+ return {
331
+ "Id": info.id,
332
+ "Name": info.name or "",
333
+ "State": {
334
+ "Status": info.state.status,
335
+ "Running": info.state.running,
336
+ "Paused": False,
337
+ "Restarting": False,
338
+ "OOMKilled": False,
339
+ "Dead": not info.state.running,
340
+ "Pid": info.state.pid or 0,
341
+ "ExitCode": 0 if info.state.running else 1,
342
+ "Error": "",
343
+ "StartedAt": info.created_at,
344
+ "FinishedAt": ""
345
+ if info.state.running
346
+ else info.created_at,
347
+ },
348
+ "Created": info.created_at,
349
+ "Image": info.image,
350
+ "Config": {
351
+ "Env": [], # BoxInfo doesn't expose env directly
352
+ },
353
+ "NetworkSettings": {
354
+ "Ports": self._format_ports(ports),
355
+ },
356
+ "HostConfig": {
357
+ "CpuCount": info.cpus,
358
+ "Memory": info.memory_mib * 1024 * 1024,
359
+ # Convert MiB to bytes
360
+ },
361
+ }
362
+ except Exception as e:
363
+ logger.warning(f"An error occurred: {e}")
364
+ logger.debug(f"{traceback.format_exc()}")
365
+ return None
366
+
367
+ def get_status(self, container_id):
368
+ """
369
+ Get the current status of the specified box.
370
+
371
+ Args:
372
+ container_id: Box ID or name
373
+
374
+ Returns:
375
+ str: Status string ('running', 'stopped', etc.) or None if not
376
+ found
377
+ """
378
+ box_attrs = self.inspect(container_id=container_id)
379
+ if box_attrs:
380
+ return box_attrs["State"]["Status"]
381
+ return None
382
+
383
+ def _find_free_ports(self, n):
384
+ """
385
+ Find n free ports in the configured port range.
386
+
387
+ Args:
388
+ n: Number of ports to find
389
+
390
+ Returns:
391
+ List of free port numbers
392
+
393
+ Raises:
394
+ RuntimeError: If not enough free ports are available
395
+ """
396
+ free_ports = []
397
+
398
+ for port in self.port_range:
399
+ if len(free_ports) >= n:
400
+ break # We have found enough ports
401
+
402
+ if not self.port_set.add(port):
403
+ continue # Port already in set
404
+
405
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
406
+ try:
407
+ s.bind(("", port))
408
+ free_ports.append(port) # Port is available
409
+ except OSError:
410
+ # Bind failed, port is in use
411
+ self.port_set.remove(port)
412
+ # Try the next one
413
+ continue
414
+
415
+ if len(free_ports) < n:
416
+ raise RuntimeError(
417
+ "Not enough free ports available in the specified range.",
418
+ )
419
+
420
+ return free_ports
421
+
422
+ def _format_ports(self, host_ports):
423
+ """
424
+ Format port list for Docker-like inspect output.
425
+
426
+ Args:
427
+ host_ports: List of host port numbers
428
+
429
+ Returns:
430
+ Dict formatted like Docker's NetworkSettings.Ports
431
+ """
432
+ if not host_ports:
433
+ return {}
434
+
435
+ ports = {}
436
+ for host_port in host_ports:
437
+ # We don't have the container port info here, so we'll use the
438
+ # host port as both host and container port
439
+ key = f"{host_port}/tcp"
440
+ ports[key] = [{"HostIp": "0.0.0.0", "HostPort": str(host_port)}]
441
+
442
+ return ports
@@ -17,26 +17,6 @@ from ..collections import (
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
- def is_port_available(port):
21
- """
22
- Check if a given port is available (not in use) on the local system.
23
-
24
- Args:
25
- port (int): The port number to check.
26
-
27
- Returns:
28
- bool: True if the port is available, False if it is in use.
29
- """
30
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
31
- try:
32
- s.bind(("", port))
33
- # Port is available
34
- return True
35
- except OSError:
36
- # Port is in use
37
- return False
38
-
39
-
40
20
  class DockerClient(BaseClient):
41
21
  def __init__(self, config=None):
42
22
  self.config = config
@@ -827,13 +827,15 @@ class FCClient(BaseClient):
827
827
  """
828
828
  replacement_map = {
829
829
  "agentscope/runtime-sandbox-base": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501
830
- "/agentscope_runtime-sandbox-base:20251027",
830
+ "/agentscope_runtime-sandbox-base:20260106",
831
831
  "agentscope/runtime-sandbox-browser": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501
832
- "/agentscope_runtime-sandbox-browser:20251027",
832
+ "/agentscope_runtime-sandbox-browser:20260106",
833
833
  "agentscope/runtime-sandbox-filesystem": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501
834
- "/agentscope_runtime-sandbox-filesystem:20251027",
834
+ "/agentscope_runtime-sandbox-filesystem:20260106",
835
835
  "agentscope/runtime-sandbox-gui": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501
836
- "/agentscope_runtime-sandbox-gui:20251027",
836
+ "/agentscope_runtime-sandbox-gui:20260106",
837
+ "agentscope/runtime-sandbox-mobile": "serverless-registry.cn-hangzhou.cr.aliyuncs.com/functionai" # noqa: E501
838
+ "/agentscope_runtime-sandbox-mobile:20251217",
837
839
  }
838
840
 
839
841
  if ":" in image:
@@ -0,0 +1,38 @@
1
+ # -*- coding: utf-8 -*-
2
+ import logging
3
+ from .docker_client import DockerClient
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class GVisorDockerClient(DockerClient):
9
+ """
10
+ A DockerClient that enforces gVisor runtime (`runsc`).
11
+ """
12
+
13
+ def create(
14
+ self,
15
+ image,
16
+ name=None,
17
+ ports=None,
18
+ volumes=None,
19
+ environment=None,
20
+ runtime_config=None,
21
+ ):
22
+ if runtime_config is None:
23
+ runtime_config = {}
24
+
25
+ runtime_config["runtime"] = "runsc"
26
+
27
+ logger.debug(
28
+ f"[GVisorDockerClient] Forcing runtime=runsc for image {image}",
29
+ )
30
+
31
+ return super().create(
32
+ image=image,
33
+ name=name,
34
+ ports=ports,
35
+ volumes=volumes,
36
+ environment=environment,
37
+ runtime_config=runtime_config,
38
+ )
@@ -12,6 +12,7 @@ from kubernetes.client.rest import ApiException
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
14
 
15
+ # TODO: not support Sandbox
15
16
  class KnativeClient:
16
17
  """
17
18
  A client for interacting with Knative Services in a Kubernetes cluster.
@@ -0,0 +1,164 @@
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ import warnings
6
+ from dataclasses import dataclass
7
+ from typing import Callable, TypeVar
8
+
9
+ T = TypeVar("T", bound=object)
10
+
11
+
12
+ def _toplevel_pkg() -> str:
13
+ pkg = __package__ or __name__
14
+ return pkg.split(".", 1)[0]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DeprecationInfo:
19
+ reason: str = ""
20
+ since: str | None = None
21
+ removed_in: str | None = None
22
+ alternative: str | None = None
23
+ issue: str | int | None = None # e.g. "GH-123" or 123
24
+
25
+
26
+ def format_deprecation_message(subject: str, info: DeprecationInfo) -> str:
27
+ parts: list[str] = [f"{subject} is deprecated."]
28
+ if info.since:
29
+ parts.append(f"Since {info.since}.")
30
+ if info.removed_in:
31
+ parts.append(f"Will be removed in {info.removed_in}.")
32
+ if info.alternative:
33
+ parts.append(f"Use {info.alternative} instead.")
34
+ if info.issue is not None:
35
+ parts.append(f"See {info.issue}.")
36
+ if info.reason:
37
+ parts.append(info.reason.rstrip(".") + ".")
38
+ return " ".join(parts)
39
+
40
+
41
+ def warn_deprecated(
42
+ subject: str,
43
+ info: DeprecationInfo,
44
+ *,
45
+ category=DeprecationWarning,
46
+ stacklevel: int = 2,
47
+ once: bool = False,
48
+ ) -> None:
49
+ message = format_deprecation_message(subject, info)
50
+
51
+ if once:
52
+ top = _toplevel_pkg()
53
+ with warnings.catch_warnings():
54
+ warnings.filterwarnings(
55
+ "once",
56
+ category=category,
57
+ module=rf"^{top}(\.|$)",
58
+ )
59
+ warnings.warn(message, category=category, stacklevel=stacklevel)
60
+ else:
61
+ warnings.warn(message, category=category, stacklevel=stacklevel)
62
+
63
+
64
+ def deprecated(
65
+ reason: str | DeprecationInfo = "",
66
+ *,
67
+ since: str | None = None,
68
+ removed_in: str | None = None,
69
+ alternative: str | None = None,
70
+ issue: str | int | None = None,
71
+ category=DeprecationWarning,
72
+ stacklevel: int = 2,
73
+ once: bool = False,
74
+ ) -> Callable[[T], T]:
75
+ """
76
+ Unified decorator:
77
+ - function/method: warn on each call
78
+ - class: warn on each instantiation (__init__)
79
+ """
80
+ info = (
81
+ reason
82
+ if isinstance(reason, DeprecationInfo)
83
+ else DeprecationInfo(
84
+ reason=str(reason),
85
+ since=since,
86
+ removed_in=removed_in,
87
+ alternative=alternative,
88
+ issue=issue,
89
+ )
90
+ )
91
+
92
+ def decorator(obj):
93
+ subject = getattr(
94
+ obj,
95
+ "__qualname__",
96
+ getattr(obj, "__name__", repr(obj)),
97
+ )
98
+
99
+ if isinstance(obj, type):
100
+ orig_init = obj.__init__
101
+
102
+ @functools.wraps(orig_init)
103
+ def __init__(self, *args, **kwargs):
104
+ warn_deprecated(
105
+ subject,
106
+ info,
107
+ category=category,
108
+ stacklevel=stacklevel,
109
+ once=once,
110
+ )
111
+ orig_init(self, *args, **kwargs)
112
+
113
+ obj.__init__ = __init__
114
+ return obj
115
+
116
+ @functools.wraps(obj)
117
+ def wrapper(*args, **kwargs):
118
+ warn_deprecated(
119
+ subject,
120
+ info,
121
+ category=category,
122
+ stacklevel=stacklevel,
123
+ once=once,
124
+ )
125
+ return obj(*args, **kwargs)
126
+
127
+ return wrapper
128
+
129
+ return decorator
130
+
131
+
132
+ def deprecated_module(
133
+ reason: str | DeprecationInfo = "",
134
+ *,
135
+ module_name: str,
136
+ since: str | None = None,
137
+ removed_in: str | None = None,
138
+ alternative: str | None = None,
139
+ issue: str | int | None = None,
140
+ category=DeprecationWarning,
141
+ stacklevel: int = 2,
142
+ once: bool = True,
143
+ ) -> None:
144
+ """
145
+ Use inside a module/package (typically __init__.py) to warn on import.
146
+ """
147
+ info = (
148
+ reason
149
+ if isinstance(reason, DeprecationInfo)
150
+ else DeprecationInfo(
151
+ reason=str(reason),
152
+ since=since,
153
+ removed_in=removed_in,
154
+ alternative=alternative,
155
+ issue=issue,
156
+ )
157
+ )
158
+ warn_deprecated(
159
+ f"Module `{module_name}`",
160
+ info,
161
+ category=category,
162
+ stacklevel=stacklevel,
163
+ once=once,
164
+ )