microsoft-teams-common 0.0.1a1__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.
- microsoft_teams_common-0.0.1a1/.gitignore +34 -0
- microsoft_teams_common-0.0.1a1/PKG-INFO +22 -0
- microsoft_teams_common-0.0.1a1/README.md +7 -0
- microsoft_teams_common-0.0.1a1/pyproject.toml +38 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/__init__.py +17 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/events/__init__.py +8 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/events/event_emitter.py +227 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/http/__init__.py +19 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/http/client.py +438 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/http/client_token.py +43 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/http/interceptor.py +42 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/logging/__init__.py +11 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/logging/ansi.py +41 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/logging/console.py +56 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/logging/filter.py +35 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/logging/formatter.py +43 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/storage/__init__.py +10 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/storage/list_local_storage.py +67 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/storage/local_storage.py +77 -0
- microsoft_teams_common-0.0.1a1/src/microsoft/teams/common/storage/storage.py +98 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
|
|
7
|
+
# Environments
|
|
8
|
+
.env
|
|
9
|
+
.venv
|
|
10
|
+
env/
|
|
11
|
+
venv/
|
|
12
|
+
ENV/
|
|
13
|
+
env.bak/
|
|
14
|
+
venv.bak/
|
|
15
|
+
|
|
16
|
+
# mypy
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.dmypy.json
|
|
19
|
+
dmypy.json
|
|
20
|
+
|
|
21
|
+
.copilot-instructions.md
|
|
22
|
+
|
|
23
|
+
# other
|
|
24
|
+
.DS_STORE
|
|
25
|
+
*.bak
|
|
26
|
+
*~
|
|
27
|
+
*.tmp
|
|
28
|
+
|
|
29
|
+
ref/
|
|
30
|
+
py.typed
|
|
31
|
+
CLAUDE.md
|
|
32
|
+
|
|
33
|
+
.env.claude/
|
|
34
|
+
.claude/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: microsoft-teams-common
|
|
3
|
+
Version: 0.0.1a1
|
|
4
|
+
Summary: Common package for Microsoft Teams
|
|
5
|
+
Project-URL: Homepage, https://github.com/microsoft/teams.py/tree/main/packages/common/src/microsoft/teams/common
|
|
6
|
+
Author-email: Microsoft <TeamsAISDKFeedback@microsoft.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agents,ai,bot,microsoft,teams
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: coverage>=7.8.0
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: pytest>=8.3.5
|
|
13
|
+
Requires-Dist: ruff>=0.11.5
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
> [!CAUTION]
|
|
17
|
+
> This project is in active development and not ready for production use. It has not been publicly announced yet.
|
|
18
|
+
|
|
19
|
+
# Microsoft Teams Common Utilities
|
|
20
|
+
|
|
21
|
+
Shared utilities including HTTP client, logging, storage, and event handling.
|
|
22
|
+
Provides common functionality used across other Teams SDK packages.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
> [!CAUTION]
|
|
2
|
+
> This project is in active development and not ready for production use. It has not been publicly announced yet.
|
|
3
|
+
|
|
4
|
+
# Microsoft Teams Common Utilities
|
|
5
|
+
|
|
6
|
+
Shared utilities including HTTP client, logging, storage, and event handling.
|
|
7
|
+
Provides common functionality used across other Teams SDK packages.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "microsoft-teams-common"
|
|
3
|
+
version = "0.0.1-alpha.1"
|
|
4
|
+
description = "Common package for Microsoft Teams"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
repository = "https://github.com/microsoft/teams.py"
|
|
7
|
+
keywords = ["microsoft", "teams", "ai", "bot", "agents"]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Microsoft", email = "TeamsAISDKFeedback@microsoft.com" }
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"coverage>=7.8.0",
|
|
15
|
+
"httpx>=0.28.1",
|
|
16
|
+
"pytest>=8.3.5",
|
|
17
|
+
"ruff>=0.11.5",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.4.0",
|
|
23
|
+
"pytest-asyncio>=1.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/microsoft/teams.py/tree/main/packages/common/src/microsoft/teams/common"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/microsoft"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from . import events, http, logging, storage # noqa: E402
|
|
7
|
+
from .events import * # noqa: F401, F402, F403
|
|
8
|
+
from .http import * # noqa: F401, F402, F403
|
|
9
|
+
from .logging import * # noqa: F401, F402, F403
|
|
10
|
+
from .storage import * # noqa: F401, F402, F403
|
|
11
|
+
|
|
12
|
+
# Combine all exports from submodules
|
|
13
|
+
__all__: list[str] = []
|
|
14
|
+
__all__.extend(events.__all__)
|
|
15
|
+
__all__.extend(http.__all__)
|
|
16
|
+
__all__.extend(logging.__all__)
|
|
17
|
+
__all__.extend(storage.__all__)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .event_emitter import EventEmitter, EventEmitterOptions, EventEmitterProtocol, EventHandler
|
|
7
|
+
|
|
8
|
+
__all__ = ["EventEmitter", "EventEmitterOptions", "EventHandler", "EventEmitterProtocol"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Awaitable, Callable, Dict, Generic, List, Optional, Protocol, TypedDict, TypeVar, Union
|
|
9
|
+
|
|
10
|
+
from ..logging import ConsoleLogger
|
|
11
|
+
|
|
12
|
+
EventTypeT = TypeVar("EventTypeT", bound=str, contravariant=True)
|
|
13
|
+
|
|
14
|
+
EventHandler = Union[
|
|
15
|
+
Callable[[Any], None], # Sync handler
|
|
16
|
+
Callable[[Any], Awaitable[Any]], # Async handler
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Subscription(TypedDict):
|
|
21
|
+
"""A subscription entry for an event handler."""
|
|
22
|
+
|
|
23
|
+
id: int
|
|
24
|
+
handler: EventHandler
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventEmitterProtocol(Protocol, Generic[EventTypeT]):
|
|
28
|
+
"""Interface for event emitter functionality."""
|
|
29
|
+
|
|
30
|
+
def on(self, event: EventTypeT, handler: EventHandler) -> int:
|
|
31
|
+
"""Register an event handler. Returns subscription ID."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def once(self, event: EventTypeT, handler: EventHandler) -> int:
|
|
35
|
+
"""Register a one-time event handler. Returns subscription ID."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def off(self, subscription_id: int) -> None:
|
|
39
|
+
"""Remove an event handler by subscription ID."""
|
|
40
|
+
|
|
41
|
+
def emit(self, event: EventTypeT, value: Any = None) -> None:
|
|
42
|
+
"""Emit an event synchronously."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EventEmitterOptions(TypedDict, total=False):
|
|
46
|
+
"""
|
|
47
|
+
Options for EventEmitter configuration.
|
|
48
|
+
|
|
49
|
+
:param logger: Custom logger instance to use for logging events
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
logger: logging.Logger
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EventEmitter(EventEmitterProtocol[EventTypeT]):
|
|
56
|
+
"""
|
|
57
|
+
Event emitter implementation inspired by TypeScript/Node.js EventEmitter.
|
|
58
|
+
|
|
59
|
+
Provides both synchronous and asynchronous event emission capabilities.
|
|
60
|
+
Thread-safe for single-threaded use cases.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, options: Optional[EventEmitterOptions] = None) -> None:
|
|
64
|
+
self._index = -1
|
|
65
|
+
self._subscriptions: Dict[str, List[Subscription]] = {}
|
|
66
|
+
|
|
67
|
+
# Use provided logger or create default console logger
|
|
68
|
+
logger = options.get("logger") if options else None
|
|
69
|
+
if logger:
|
|
70
|
+
self._logger = logger.getChild("microsoft.teams.common.events.EventEmitter")
|
|
71
|
+
else:
|
|
72
|
+
self._logger = ConsoleLogger().create_logger("microsoft.teams.common.events.EventEmitter")
|
|
73
|
+
|
|
74
|
+
def on(self, event: EventTypeT, handler: EventHandler) -> int:
|
|
75
|
+
"""
|
|
76
|
+
Register an event handler.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
event: Event name to listen for
|
|
80
|
+
handler: Function to call when event is emitted
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Subscription ID for removing the handler later
|
|
84
|
+
"""
|
|
85
|
+
subscription_id = self._get_next_id()
|
|
86
|
+
|
|
87
|
+
if event not in self._subscriptions:
|
|
88
|
+
self._subscriptions[event] = []
|
|
89
|
+
|
|
90
|
+
self._subscriptions[event].append({"id": subscription_id, "handler": handler})
|
|
91
|
+
|
|
92
|
+
self._logger.debug("Registered handler for event '%s' with id %d", event, subscription_id)
|
|
93
|
+
return subscription_id
|
|
94
|
+
|
|
95
|
+
def once(self, event: EventTypeT, handler: EventHandler) -> int:
|
|
96
|
+
"""
|
|
97
|
+
Register a one-time event handler that will be removed after first execution.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
event: Event name to listen for
|
|
101
|
+
handler: Function to call when event is emitted
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Subscription ID for removing the handler later
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def once_wrapper(value: Any) -> None:
|
|
108
|
+
self.off(subscription_id)
|
|
109
|
+
handler(value)
|
|
110
|
+
|
|
111
|
+
subscription_id = self._get_next_id()
|
|
112
|
+
|
|
113
|
+
if event not in self._subscriptions:
|
|
114
|
+
self._subscriptions[event] = []
|
|
115
|
+
|
|
116
|
+
self._subscriptions[event].append({"id": subscription_id, "handler": once_wrapper})
|
|
117
|
+
|
|
118
|
+
self._logger.debug("Registered one-time handler for event '%s' with id %d", event, subscription_id)
|
|
119
|
+
return subscription_id
|
|
120
|
+
|
|
121
|
+
def off(self, subscription_id: int) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Remove an event handler by subscription ID.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
subscription_id: ID returned from on() or once()
|
|
127
|
+
"""
|
|
128
|
+
for event_name, subscriptions in list(self._subscriptions.items()):
|
|
129
|
+
for i, subscription in enumerate(subscriptions):
|
|
130
|
+
if subscription["id"] == subscription_id:
|
|
131
|
+
subscriptions.pop(i)
|
|
132
|
+
self._logger.debug("Removed handler with id %d from event '%s'", subscription_id, event_name)
|
|
133
|
+
if not subscriptions:
|
|
134
|
+
del self._subscriptions[event_name]
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
def emit(self, event: EventTypeT, value: Any = None) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Emit an event synchronously to all registered handlers.
|
|
140
|
+
|
|
141
|
+
Async handlers are run using asyncio.run() in a separate event loop.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
event: Event name to emit
|
|
145
|
+
value: Data to pass to event handlers
|
|
146
|
+
"""
|
|
147
|
+
if event not in self._subscriptions:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
handler_count = len(self._subscriptions[event])
|
|
151
|
+
self._logger.debug("Emitting event '%s' to %d handler(s)", event, handler_count)
|
|
152
|
+
|
|
153
|
+
awaitables: list[Awaitable[None]] = []
|
|
154
|
+
|
|
155
|
+
for subscription in self._subscriptions[event][:]: # Copy to avoid modification during iteration
|
|
156
|
+
try:
|
|
157
|
+
handler = subscription["handler"]
|
|
158
|
+
|
|
159
|
+
if asyncio.iscoroutinefunction(handler):
|
|
160
|
+
awaitables.append(handler(value))
|
|
161
|
+
else:
|
|
162
|
+
handler(value)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
# Continue executing other handlers even if one fails
|
|
165
|
+
self._logger.error("Handler failed for event '%s': %s", event, e)
|
|
166
|
+
|
|
167
|
+
# Handle awaitables if any exist
|
|
168
|
+
if awaitables:
|
|
169
|
+
self._logger.debug("Running %d async handler(s) for event '%s'", len(awaitables), event)
|
|
170
|
+
|
|
171
|
+
async def run_async_handlers() -> None:
|
|
172
|
+
results = await asyncio.gather(*awaitables, return_exceptions=True)
|
|
173
|
+
for i, result in enumerate(results):
|
|
174
|
+
if isinstance(result, Exception):
|
|
175
|
+
self._logger.error("Async handler %d failed for event '%s': %s", i, event, result)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
# Try to get the current event loop
|
|
179
|
+
loop = asyncio.get_running_loop()
|
|
180
|
+
# If loop is running, schedule the async handlers as tasks
|
|
181
|
+
loop.create_task(run_async_handlers())
|
|
182
|
+
# Note: tasks run in background, emit() doesn't wait for completion
|
|
183
|
+
except RuntimeError:
|
|
184
|
+
# No event loop running, create one
|
|
185
|
+
asyncio.run(run_async_handlers())
|
|
186
|
+
|
|
187
|
+
def listener_count(self, event: str) -> int:
|
|
188
|
+
"""
|
|
189
|
+
Get the number of listeners for an event.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
event: Event name
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Number of registered listeners
|
|
196
|
+
"""
|
|
197
|
+
return len(self._subscriptions.get(event, []))
|
|
198
|
+
|
|
199
|
+
def event_names(self) -> List[str]:
|
|
200
|
+
"""
|
|
201
|
+
Get list of event names that have listeners.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of event names
|
|
205
|
+
"""
|
|
206
|
+
return list(self._subscriptions.keys())
|
|
207
|
+
|
|
208
|
+
def remove_all_listeners(self, event: Optional[str] = None) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Remove all listeners for a specific event or all events.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
event: Event name to clear. If None, clears all events.
|
|
214
|
+
"""
|
|
215
|
+
if event is None:
|
|
216
|
+
total_handlers = sum(len(handlers) for handlers in self._subscriptions.values())
|
|
217
|
+
self._subscriptions.clear()
|
|
218
|
+
self._logger.debug("Removed all %d handler(s) from all events", total_handlers)
|
|
219
|
+
elif event in self._subscriptions:
|
|
220
|
+
handler_count = len(self._subscriptions[event])
|
|
221
|
+
del self._subscriptions[event]
|
|
222
|
+
self._logger.debug("Removed all %d handler(s) from event '%s'", handler_count, event)
|
|
223
|
+
|
|
224
|
+
def _get_next_id(self) -> int:
|
|
225
|
+
"""Get next unique subscription ID."""
|
|
226
|
+
self._index += 1
|
|
227
|
+
return self._index
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import Client, ClientOptions
|
|
7
|
+
from .client_token import Token, TokenFactory, resolve_token
|
|
8
|
+
from .interceptor import Interceptor, InterceptorRequestContext, InterceptorResponseContext
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Client",
|
|
12
|
+
"ClientOptions",
|
|
13
|
+
"Interceptor",
|
|
14
|
+
"InterceptorRequestContext",
|
|
15
|
+
"InterceptorResponseContext",
|
|
16
|
+
"Token",
|
|
17
|
+
"TokenFactory",
|
|
18
|
+
"resolve_token",
|
|
19
|
+
]
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from httpx._models import Request, Response
|
|
14
|
+
from httpx._types import QueryParamTypes, RequestContent, RequestData, RequestFiles
|
|
15
|
+
|
|
16
|
+
from ..logging import ConsoleLogger
|
|
17
|
+
from .client_token import Token, resolve_token
|
|
18
|
+
from .interceptor import Interceptor, InterceptorRequestContext, InterceptorResponseContext
|
|
19
|
+
|
|
20
|
+
console_logger = ConsoleLogger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _wrap_response_json(response: httpx.Response, logger: logging.Logger) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Wrap the response.json method to handle JSONDecodeError gracefully.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
response: The httpx.Response object to wrap.
|
|
29
|
+
logger: Logger instance for warning messages.
|
|
30
|
+
"""
|
|
31
|
+
original_json = response.json
|
|
32
|
+
|
|
33
|
+
def safe_json(**kwargs: Any) -> Any:
|
|
34
|
+
try:
|
|
35
|
+
return original_json(**kwargs)
|
|
36
|
+
except json.JSONDecodeError as e:
|
|
37
|
+
if e.pos == 0:
|
|
38
|
+
logger.warning(f"Failed to decode JSON response from {response.url}. Returning empty dict.")
|
|
39
|
+
return {}
|
|
40
|
+
else:
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
response.json = safe_json
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ClientOptions:
|
|
48
|
+
"""
|
|
49
|
+
Configuration options for the HTTP Client.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
base_url: The base URL for all requests.
|
|
53
|
+
headers: Default headers to include with every request.
|
|
54
|
+
timeout: Default request timeout in seconds.
|
|
55
|
+
logger: Logger instance for request/response/error logging.
|
|
56
|
+
token: Default authorization token (string, string-like, or callable).
|
|
57
|
+
interceptors: List of interceptors for request/response middleware.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
base_url: Optional[str] = None
|
|
61
|
+
headers: Dict[str, str] = field(default_factory=dict[str, str])
|
|
62
|
+
timeout: Optional[float] = None
|
|
63
|
+
logger: Optional[logging.Logger] = None
|
|
64
|
+
token: Optional[Token] = None
|
|
65
|
+
interceptors: Optional[List[Interceptor]] = field(default_factory=list[Interceptor])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Client:
|
|
69
|
+
"""
|
|
70
|
+
HTTP Client abstraction for making requests with configurable options.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
options: ClientOptions dataclass with configuration for the client.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, options: Optional[ClientOptions] = None):
|
|
77
|
+
"""
|
|
78
|
+
Initialize the HTTP Client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
options: Optional ClientOptions dataclass with configuration for the client.
|
|
82
|
+
"""
|
|
83
|
+
if options is None:
|
|
84
|
+
options = ClientOptions()
|
|
85
|
+
|
|
86
|
+
self._options = options
|
|
87
|
+
self._logger = options.logger or console_logger.create_logger("http-client")
|
|
88
|
+
self._token = options.token
|
|
89
|
+
|
|
90
|
+
# Configure httpx logging to match our logger's level
|
|
91
|
+
httpx_logger = logging.getLogger("httpx")
|
|
92
|
+
httpx_logger.setLevel(self._logger.level)
|
|
93
|
+
|
|
94
|
+
# Maintain interceptors as a separate instance attribute (do not mutate options)
|
|
95
|
+
self._interceptors = list(options.interceptors or [])
|
|
96
|
+
|
|
97
|
+
self.http = httpx.AsyncClient(
|
|
98
|
+
base_url=httpx.URL(options.base_url) if options.base_url else "",
|
|
99
|
+
headers=options.headers,
|
|
100
|
+
timeout=options.timeout,
|
|
101
|
+
)
|
|
102
|
+
self._update_event_hooks()
|
|
103
|
+
|
|
104
|
+
async def _prepare_headers(self, headers: Optional[Dict[str, str]], token: Optional[Token]) -> Dict[str, str]:
|
|
105
|
+
"""
|
|
106
|
+
Merge default and per-request headers, resolve token, and inject Authorization header if needed.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
headers: Optional per-request headers.
|
|
110
|
+
token: Optional per-request token.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Final headers dict for the request.
|
|
114
|
+
"""
|
|
115
|
+
req_headers = {**self._options.headers, **(headers or {})}
|
|
116
|
+
resolved_token = await self._resolve_token(token)
|
|
117
|
+
if resolved_token:
|
|
118
|
+
req_headers["Authorization"] = f"Bearer {resolved_token}"
|
|
119
|
+
return req_headers
|
|
120
|
+
|
|
121
|
+
async def get(
|
|
122
|
+
self,
|
|
123
|
+
url: str,
|
|
124
|
+
*,
|
|
125
|
+
headers: Optional[Dict[str, str]] = None,
|
|
126
|
+
token: Optional[Token] = None,
|
|
127
|
+
params: Optional[QueryParamTypes] = None,
|
|
128
|
+
**kwargs: Any,
|
|
129
|
+
) -> httpx.Response:
|
|
130
|
+
"""
|
|
131
|
+
Send a GET request.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
url: The URL path or full URL.
|
|
135
|
+
headers: Optional per-request headers.
|
|
136
|
+
params: Optional query parameters.
|
|
137
|
+
token: Optional per-request token (overrides default).
|
|
138
|
+
**kwargs: Additional httpx.AsyncClient.get arguments.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
httpx.Response
|
|
142
|
+
"""
|
|
143
|
+
req_headers = await self._prepare_headers(headers, token)
|
|
144
|
+
response = await self.http.get(url, headers=req_headers, params=params, **kwargs)
|
|
145
|
+
response.raise_for_status()
|
|
146
|
+
_wrap_response_json(response, self._logger)
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
async def post(
|
|
150
|
+
self,
|
|
151
|
+
url: str,
|
|
152
|
+
*,
|
|
153
|
+
content: Optional[RequestContent] = None,
|
|
154
|
+
data: Optional[RequestData] = None,
|
|
155
|
+
files: Optional[RequestFiles] = None,
|
|
156
|
+
json: Optional[Any] = None,
|
|
157
|
+
params: Optional[QueryParamTypes] = None,
|
|
158
|
+
headers: Optional[Dict[str, str]] = None,
|
|
159
|
+
token: Optional[Token] = None,
|
|
160
|
+
**kwargs: Any,
|
|
161
|
+
) -> httpx.Response:
|
|
162
|
+
"""
|
|
163
|
+
Send a POST request.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
url: The URL path or full URL.
|
|
167
|
+
data: The request body.
|
|
168
|
+
headers: Optional per-request headers.
|
|
169
|
+
params: Optional query parameters.
|
|
170
|
+
content: The request body.
|
|
171
|
+
files: The request files.
|
|
172
|
+
json: The request JSON body.
|
|
173
|
+
token: Optional per-request token (overrides default).
|
|
174
|
+
**kwargs: Additional httpx.AsyncClient.post arguments.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
httpx.Response
|
|
178
|
+
"""
|
|
179
|
+
req_headers = await self._prepare_headers(headers, token)
|
|
180
|
+
response = await self.http.post(
|
|
181
|
+
url,
|
|
182
|
+
data=data,
|
|
183
|
+
files=files,
|
|
184
|
+
json=json,
|
|
185
|
+
content=content,
|
|
186
|
+
params=params,
|
|
187
|
+
headers=req_headers,
|
|
188
|
+
**kwargs,
|
|
189
|
+
)
|
|
190
|
+
response.raise_for_status()
|
|
191
|
+
_wrap_response_json(response, self._logger)
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
async def put(
|
|
195
|
+
self,
|
|
196
|
+
url: str,
|
|
197
|
+
*,
|
|
198
|
+
content: Optional[RequestContent] = None,
|
|
199
|
+
data: Optional[RequestData] = None,
|
|
200
|
+
files: Optional[RequestFiles] = None,
|
|
201
|
+
json: Optional[Any] = None,
|
|
202
|
+
params: Optional[QueryParamTypes] = None,
|
|
203
|
+
headers: Optional[Dict[str, str]] = None,
|
|
204
|
+
token: Optional[Token] = None,
|
|
205
|
+
**kwargs: Any,
|
|
206
|
+
) -> httpx.Response:
|
|
207
|
+
"""
|
|
208
|
+
Send a PUT request.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
url: The URL path or full URL.
|
|
212
|
+
data: The request body.
|
|
213
|
+
headers: Optional per-request headers.
|
|
214
|
+
params: Optional query parameters.
|
|
215
|
+
content: The request body.
|
|
216
|
+
files: The request files.
|
|
217
|
+
json: The request JSON body.
|
|
218
|
+
token: Optional per-request token (overrides default).
|
|
219
|
+
**kwargs: Additional httpx.AsyncClient.put arguments.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
httpx.Response
|
|
223
|
+
"""
|
|
224
|
+
req_headers = await self._prepare_headers(headers, token)
|
|
225
|
+
response = await self.http.put(
|
|
226
|
+
url,
|
|
227
|
+
data=data,
|
|
228
|
+
files=files,
|
|
229
|
+
json=json,
|
|
230
|
+
content=content,
|
|
231
|
+
params=params,
|
|
232
|
+
headers=req_headers,
|
|
233
|
+
**kwargs,
|
|
234
|
+
)
|
|
235
|
+
response.raise_for_status()
|
|
236
|
+
_wrap_response_json(response, self._logger)
|
|
237
|
+
return response
|
|
238
|
+
|
|
239
|
+
async def patch(
|
|
240
|
+
self,
|
|
241
|
+
url: str,
|
|
242
|
+
*,
|
|
243
|
+
content: Optional[RequestContent] = None,
|
|
244
|
+
data: Optional[RequestData] = None,
|
|
245
|
+
files: Optional[RequestFiles] = None,
|
|
246
|
+
json: Optional[Any] = None,
|
|
247
|
+
params: Optional[QueryParamTypes] = None,
|
|
248
|
+
headers: Optional[Dict[str, str]] = None,
|
|
249
|
+
token: Optional[Token] = None,
|
|
250
|
+
**kwargs: Any,
|
|
251
|
+
) -> httpx.Response:
|
|
252
|
+
"""
|
|
253
|
+
Send a PATCH request.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
url: The URL path or full URL.
|
|
257
|
+
data: The request body.
|
|
258
|
+
headers: Optional per-request headers.
|
|
259
|
+
params: Optional query parameters.
|
|
260
|
+
content: The request body.
|
|
261
|
+
files: The request files.
|
|
262
|
+
json: The request JSON body.
|
|
263
|
+
token: Optional per-request token (overrides default).
|
|
264
|
+
**kwargs: Additional httpx.AsyncClient.patch arguments.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
httpx.Response
|
|
268
|
+
"""
|
|
269
|
+
req_headers = await self._prepare_headers(headers, token)
|
|
270
|
+
response = await self.http.patch(
|
|
271
|
+
url,
|
|
272
|
+
data=data,
|
|
273
|
+
files=files,
|
|
274
|
+
json=json,
|
|
275
|
+
content=content,
|
|
276
|
+
params=params,
|
|
277
|
+
headers=req_headers,
|
|
278
|
+
**kwargs,
|
|
279
|
+
)
|
|
280
|
+
response.raise_for_status()
|
|
281
|
+
_wrap_response_json(response, self._logger)
|
|
282
|
+
return response
|
|
283
|
+
|
|
284
|
+
async def delete(
|
|
285
|
+
self,
|
|
286
|
+
url: str,
|
|
287
|
+
*,
|
|
288
|
+
headers: Optional[Dict[str, str]] = None,
|
|
289
|
+
token: Optional[Token] = None,
|
|
290
|
+
params: Optional[QueryParamTypes] = None,
|
|
291
|
+
**kwargs: Any,
|
|
292
|
+
) -> httpx.Response:
|
|
293
|
+
"""
|
|
294
|
+
Send a DELETE request.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
url: The URL path or full URL.
|
|
298
|
+
headers: Optional per-request headers.
|
|
299
|
+
params: Optional query parameters.
|
|
300
|
+
token: Optional per-request token (overrides default).
|
|
301
|
+
**kwargs: Additional httpx.AsyncClient.delete arguments.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
httpx.Response
|
|
305
|
+
"""
|
|
306
|
+
req_headers = await self._prepare_headers(headers, token)
|
|
307
|
+
response = await self.http.delete(url, headers=req_headers, params=params, **kwargs)
|
|
308
|
+
response.raise_for_status()
|
|
309
|
+
_wrap_response_json(response, self._logger)
|
|
310
|
+
return response
|
|
311
|
+
|
|
312
|
+
async def request(
|
|
313
|
+
self,
|
|
314
|
+
method: str,
|
|
315
|
+
url: str,
|
|
316
|
+
*,
|
|
317
|
+
params: Optional[QueryParamTypes] = None,
|
|
318
|
+
headers: Optional[Dict[str, str]] = None,
|
|
319
|
+
token: Optional[Token] = None,
|
|
320
|
+
content: Optional[RequestContent] = None,
|
|
321
|
+
data: Optional[RequestData] = None,
|
|
322
|
+
files: Optional[RequestFiles] = None,
|
|
323
|
+
json: Optional[Any] = None,
|
|
324
|
+
**kwargs: Any,
|
|
325
|
+
) -> httpx.Response:
|
|
326
|
+
"""
|
|
327
|
+
Send a custom HTTP request.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
method: HTTP method (GET, POST, etc).
|
|
331
|
+
url: The URL path or full URL.
|
|
332
|
+
headers: Optional per-request headers.
|
|
333
|
+
params: Optional query parameters.
|
|
334
|
+
content: The request body.
|
|
335
|
+
data: The request body.
|
|
336
|
+
files: The request files.
|
|
337
|
+
json: The request JSON body.
|
|
338
|
+
token: Optional per-request token (overrides default).
|
|
339
|
+
**kwargs: Additional httpx.AsyncClient.request arguments.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
httpx.Response
|
|
343
|
+
"""
|
|
344
|
+
req_headers = await self._prepare_headers(headers, token)
|
|
345
|
+
response = await self.http.request(
|
|
346
|
+
method,
|
|
347
|
+
url,
|
|
348
|
+
headers=req_headers,
|
|
349
|
+
params=params,
|
|
350
|
+
content=content,
|
|
351
|
+
data=data,
|
|
352
|
+
files=files,
|
|
353
|
+
json=json,
|
|
354
|
+
**kwargs,
|
|
355
|
+
)
|
|
356
|
+
response.raise_for_status()
|
|
357
|
+
_wrap_response_json(response, self._logger)
|
|
358
|
+
return response
|
|
359
|
+
|
|
360
|
+
async def _resolve_token(self, token: Optional[Token]) -> Optional[str]:
|
|
361
|
+
"""
|
|
362
|
+
Resolve the token to a string, using per-request or default token.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
token: Per-request token or None.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
The resolved token string or None.
|
|
369
|
+
"""
|
|
370
|
+
use_token = token if token is not None else self._token
|
|
371
|
+
if use_token is None:
|
|
372
|
+
return None
|
|
373
|
+
return await resolve_token(use_token)
|
|
374
|
+
|
|
375
|
+
def use_interceptor(self, interceptor: Interceptor) -> None:
|
|
376
|
+
"""
|
|
377
|
+
Register an interceptor for request/response middleware.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
interceptor: An object with optional request/response methods.
|
|
381
|
+
"""
|
|
382
|
+
self._interceptors.append(interceptor)
|
|
383
|
+
self._update_event_hooks()
|
|
384
|
+
|
|
385
|
+
def _update_event_hooks(self) -> None:
|
|
386
|
+
"""
|
|
387
|
+
Internal: Update the httpx.AsyncClient event_hooks to match current interceptors.
|
|
388
|
+
"""
|
|
389
|
+
event_hooks_dict: Dict[str, List[Callable[[Any], Any]]] = {}
|
|
390
|
+
for hook in self._interceptors:
|
|
391
|
+
if hasattr(hook, "request"):
|
|
392
|
+
|
|
393
|
+
def _make_request_wrapper(h: Interceptor) -> Callable[[Request], Awaitable[None]]:
|
|
394
|
+
async def wrapper(request: Request) -> None:
|
|
395
|
+
ctx = InterceptorRequestContext(request, self._logger)
|
|
396
|
+
result = h.request(ctx)
|
|
397
|
+
if inspect.isawaitable(result):
|
|
398
|
+
await result
|
|
399
|
+
|
|
400
|
+
return wrapper
|
|
401
|
+
|
|
402
|
+
event_hooks_dict.setdefault("request", []).append(_make_request_wrapper(hook))
|
|
403
|
+
if hasattr(hook, "response"):
|
|
404
|
+
|
|
405
|
+
def _make_response_wrapper(h: Interceptor) -> Callable[[Response], Awaitable[None]]:
|
|
406
|
+
async def wrapper(response: Response) -> None:
|
|
407
|
+
ctx = InterceptorResponseContext(response, self._logger)
|
|
408
|
+
result = h.response(ctx)
|
|
409
|
+
if inspect.isawaitable(result):
|
|
410
|
+
await result
|
|
411
|
+
|
|
412
|
+
return wrapper
|
|
413
|
+
|
|
414
|
+
event_hooks_dict.setdefault("response", []).append(_make_response_wrapper(hook))
|
|
415
|
+
self.http.event_hooks = event_hooks_dict
|
|
416
|
+
|
|
417
|
+
def clone(self, overrides: Optional[ClientOptions] = None) -> "Client":
|
|
418
|
+
"""
|
|
419
|
+
Create a new Client instance with merged configuration.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
overrides: Optional ClientOptions object to override fields.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
A new Client instance with merged options and a cloned interceptor list.
|
|
426
|
+
"""
|
|
427
|
+
overrides = overrides or ClientOptions()
|
|
428
|
+
merged_options = ClientOptions(
|
|
429
|
+
base_url=overrides.base_url if overrides.base_url is not None else self._options.base_url,
|
|
430
|
+
headers={**self._options.headers, **(overrides.headers or {})},
|
|
431
|
+
timeout=overrides.timeout if overrides.timeout is not None else self._options.timeout,
|
|
432
|
+
logger=overrides.logger if overrides.logger is not None else self._options.logger,
|
|
433
|
+
token=overrides.token if overrides.token is not None else self._options.token,
|
|
434
|
+
interceptors=list(overrides.interceptors)
|
|
435
|
+
if overrides.interceptors is not None
|
|
436
|
+
else list(self._interceptors),
|
|
437
|
+
)
|
|
438
|
+
return Client(merged_options)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
from typing import Awaitable, Callable, Optional, Protocol, Union, runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# String-like protocol: any object with __str__
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class StringLike(Protocol):
|
|
13
|
+
def __str__(self) -> str: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TokenFactory = Callable[
|
|
17
|
+
[],
|
|
18
|
+
Union[
|
|
19
|
+
str,
|
|
20
|
+
StringLike,
|
|
21
|
+
None,
|
|
22
|
+
Awaitable[Union[str, StringLike, None]],
|
|
23
|
+
],
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
Token = Union[str, StringLike, TokenFactory, None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def resolve_token(token: Token) -> Optional[str]:
|
|
30
|
+
"""
|
|
31
|
+
Resolves a token value to a string, handling callables and awaitables.
|
|
32
|
+
Always used as an async function for uniform async usage.
|
|
33
|
+
"""
|
|
34
|
+
value = token
|
|
35
|
+
if callable(value):
|
|
36
|
+
called_value = value()
|
|
37
|
+
if inspect.isawaitable(called_value):
|
|
38
|
+
resolved = await called_value
|
|
39
|
+
return str(resolved) if resolved is not None else None
|
|
40
|
+
return str(called_value) if called_value is not None else None
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
return str(value)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Awaitable, Callable, TypeVar, Union
|
|
8
|
+
|
|
9
|
+
from httpx import Request, Response
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
D = TypeVar("D")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InterceptorRequestContext:
|
|
16
|
+
def __init__(self, request: Request, log: logging.Logger):
|
|
17
|
+
self.request = request
|
|
18
|
+
self.log = log
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InterceptorResponseContext:
|
|
22
|
+
def __init__(self, response: Response, log: logging.Logger):
|
|
23
|
+
self.response = response
|
|
24
|
+
self.log = log
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
RequestInterceptor = Callable[[InterceptorRequestContext], Union[Any, Awaitable[Any]]]
|
|
28
|
+
ResponseInterceptor = Callable[[InterceptorResponseContext], Union[Any, Awaitable[Any]]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Interceptor:
|
|
32
|
+
"""
|
|
33
|
+
Protocol for HTTP interceptors.
|
|
34
|
+
|
|
35
|
+
Optionally implement any of:
|
|
36
|
+
- request(ctx): mutate or observe outgoing request (ctx: InterceptorRequestContext)
|
|
37
|
+
- response(ctx): mutate or observe incoming response (ctx: InterceptorResponseContext)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def request(self, ctx: InterceptorRequestContext) -> Union[None, Awaitable[None]]: ...
|
|
41
|
+
|
|
42
|
+
def response(self, ctx: InterceptorResponseContext) -> Union[None, Awaitable[None]]: ...
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .ansi import ANSI
|
|
7
|
+
from .console import ConsoleLogger
|
|
8
|
+
from .filter import ConsoleFilter
|
|
9
|
+
from .formatter import ConsoleFormatter
|
|
10
|
+
|
|
11
|
+
__all__ = ["ANSI", "ConsoleLogger", "ConsoleFormatter", "ConsoleFilter"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ANSI(str, Enum):
|
|
10
|
+
RESET = "\033[0m"
|
|
11
|
+
|
|
12
|
+
BOLD = "\033[1m"
|
|
13
|
+
BOLD_RESET = "\033[22m"
|
|
14
|
+
ITALIC = "\033[3m"
|
|
15
|
+
ITALIC_RESET = "\033[23m"
|
|
16
|
+
UNDERLINE = "\033[4m"
|
|
17
|
+
UNDERLINE_RESET = "\033[24m"
|
|
18
|
+
STRIKE = "\033[9m"
|
|
19
|
+
STRIKE_RESET = "\033[29m"
|
|
20
|
+
|
|
21
|
+
FOREGROUND_RESET = "\033[0m"
|
|
22
|
+
BACKGROUND_RESET = "\033[0m"
|
|
23
|
+
FOREGROUND_BLACK = "\033[30m"
|
|
24
|
+
BACKGROUND_BLACK = "\033[40m"
|
|
25
|
+
FOREGROUND_RED = "\033[31m"
|
|
26
|
+
BACKGROUND_RED = "\033[41m"
|
|
27
|
+
FOREGROUND_GREEN = "\033[32m"
|
|
28
|
+
BACKGROUND_GREEN = "\033[42m"
|
|
29
|
+
FOREGROUND_YELLOW = "\033[33m"
|
|
30
|
+
BACKGROUND_YELLOW = "\033[43m"
|
|
31
|
+
FOREGROUND_BLUE = "\033[34m"
|
|
32
|
+
BACKGROUND_BLUE = "\033[44m"
|
|
33
|
+
FOREGROUND_MAGENTA = "\033[35m"
|
|
34
|
+
BACKGROUND_MAGENTA = "\033[45m"
|
|
35
|
+
FOREGROUND_CYAN = "\033[36m"
|
|
36
|
+
BACKGROUND_CYAN = "\033[46m"
|
|
37
|
+
FOREGROUND_WHITE = "\033[37m"
|
|
38
|
+
BACKGROUND_WHITE = "\033[47m"
|
|
39
|
+
FOREGROUND_GRAY = "\033[90m"
|
|
40
|
+
FOREGROUND_DEFAULT = "\033[39m"
|
|
41
|
+
BACKGROUND_DEFAULT = "\033[49m"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional, TypedDict
|
|
9
|
+
|
|
10
|
+
from .filter import ConsoleFilter
|
|
11
|
+
from .formatter import ConsoleFormatter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleLoggerOptions(TypedDict, total=False):
|
|
15
|
+
"""
|
|
16
|
+
ConsoleLoggerOptions is a dictionary that contains the options for the ConsoleLogger.
|
|
17
|
+
|
|
18
|
+
:param level: The level of the logger (error, warn, info, debug)
|
|
19
|
+
:param pattern: The pattern of the logger (e.g. "my_module.*")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
level: str
|
|
23
|
+
pattern: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConsoleLogger:
|
|
27
|
+
"""
|
|
28
|
+
ConsoleLogger is a class that creates a logger for the console.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_levels = {"error": logging.ERROR, "warn": logging.WARNING, "info": logging.INFO, "debug": logging.DEBUG}
|
|
32
|
+
|
|
33
|
+
def create_logger(self, name: str, options: Optional[ConsoleLoggerOptions] = None) -> logging.Logger:
|
|
34
|
+
"""
|
|
35
|
+
Create a logger for the console.
|
|
36
|
+
|
|
37
|
+
:param name: The name of the logger
|
|
38
|
+
:param options: The options for the logger
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(name)
|
|
42
|
+
logger.handlers = []
|
|
43
|
+
|
|
44
|
+
handler = logging.StreamHandler()
|
|
45
|
+
handler.setFormatter(ConsoleFormatter())
|
|
46
|
+
logger.addHandler(handler)
|
|
47
|
+
|
|
48
|
+
options_level = options.get("level", "info") if options else "info"
|
|
49
|
+
level = (os.environ.get("LOG_LEVEL") or options_level).lower()
|
|
50
|
+
logger.setLevel(self._levels.get(level, logging.INFO))
|
|
51
|
+
|
|
52
|
+
options_pattern = options.get("pattern", "*") if options else "*"
|
|
53
|
+
pattern = os.environ.get("LOG") or options_pattern
|
|
54
|
+
logger.addFilter(ConsoleFilter(pattern))
|
|
55
|
+
|
|
56
|
+
return logger
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConsoleFilter(logging.Filter):
|
|
11
|
+
"""
|
|
12
|
+
A logging filter that matches log records against a pattern.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
pattern (str): The pattern to match against log record names.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, pattern: str = "*"):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.pattern = self._parse_magic_expr(pattern)
|
|
21
|
+
|
|
22
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Filter log records based on a pattern.
|
|
25
|
+
Args:
|
|
26
|
+
record (logging.LogRecord): The log record to filter.
|
|
27
|
+
Returns:
|
|
28
|
+
bool: True if the record matches the pattern, False otherwise.
|
|
29
|
+
"""
|
|
30
|
+
return bool(self.pattern.match(record.name))
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _parse_magic_expr(pattern: str) -> re.Pattern[str]:
|
|
34
|
+
pattern = pattern.replace("*", ".*")
|
|
35
|
+
return re.compile(f"^{pattern}$")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from .ansi import ANSI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConsoleFormatter(logging.Formatter):
|
|
13
|
+
"""
|
|
14
|
+
A custom logging formatter that formats log messages with colors and prefixes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_colors = {
|
|
18
|
+
"ERROR": ANSI.FOREGROUND_RED,
|
|
19
|
+
"WARNING": ANSI.FOREGROUND_YELLOW,
|
|
20
|
+
"INFO": ANSI.FOREGROUND_CYAN,
|
|
21
|
+
"DEBUG": ANSI.FOREGROUND_MAGENTA,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Format the log record with colors and prefixes.
|
|
27
|
+
Args:
|
|
28
|
+
record (logging.LogRecord): The log record to format.
|
|
29
|
+
Returns:
|
|
30
|
+
str: The formatted log message.
|
|
31
|
+
"""
|
|
32
|
+
if isinstance(record.msg, (dict, list)):
|
|
33
|
+
record.msg = json.dumps(record.msg, indent=2) # pyright: ignore[reportUnknownMemberType]
|
|
34
|
+
|
|
35
|
+
level_name = record.levelname.upper()
|
|
36
|
+
color = self._colors.get(level_name, ANSI.FOREGROUND_CYAN)
|
|
37
|
+
prefix = f"{color.value}{ANSI.BOLD.value}[{level_name}]"
|
|
38
|
+
name = f"{record.name}{ANSI.FOREGROUND_RESET.value}{ANSI.BOLD_RESET.value}"
|
|
39
|
+
|
|
40
|
+
message = record.getMessage()
|
|
41
|
+
lines = message.split("\n")
|
|
42
|
+
formatted_lines = [f"{prefix} {name} {line}" for line in lines]
|
|
43
|
+
return "\n".join(formatted_lines)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .list_local_storage import ListLocalStorage
|
|
7
|
+
from .local_storage import LocalStorage, LocalStorageOptions
|
|
8
|
+
from .storage import ListStorage, Storage
|
|
9
|
+
|
|
10
|
+
__all__ = ["Storage", "ListStorage", "LocalStorage", "ListLocalStorage", "LocalStorageOptions"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, List, Optional, TypeVar
|
|
7
|
+
|
|
8
|
+
from .storage import ListStorage
|
|
9
|
+
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ListLocalStorage(ListStorage[V]):
|
|
14
|
+
"""A local storage implementation for a list of items."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, items: Optional[List[V]] = None):
|
|
17
|
+
self._items = items or []
|
|
18
|
+
|
|
19
|
+
def get(self, key: int) -> Optional[V]:
|
|
20
|
+
if key < 0 or key >= len(self._items):
|
|
21
|
+
return None
|
|
22
|
+
return self._items[key]
|
|
23
|
+
|
|
24
|
+
async def async_get(self, key: int) -> Optional[V]:
|
|
25
|
+
return self.get(key)
|
|
26
|
+
|
|
27
|
+
def set(self, key: int, value: V) -> None:
|
|
28
|
+
self._items[key] = value
|
|
29
|
+
|
|
30
|
+
async def async_set(self, key: int, value: V) -> None:
|
|
31
|
+
return self.set(key, value)
|
|
32
|
+
|
|
33
|
+
def delete(self, key: int) -> None:
|
|
34
|
+
del self._items[key]
|
|
35
|
+
|
|
36
|
+
async def async_delete(self, key: int) -> None:
|
|
37
|
+
return self.delete(key)
|
|
38
|
+
|
|
39
|
+
def append(self, value: V) -> None:
|
|
40
|
+
return self._items.append(value)
|
|
41
|
+
|
|
42
|
+
async def async_append(self, value: V) -> None:
|
|
43
|
+
return self.append(value)
|
|
44
|
+
|
|
45
|
+
def pop(self) -> Optional[V]:
|
|
46
|
+
return self._items.pop()
|
|
47
|
+
|
|
48
|
+
async def async_pop(self) -> Optional[V]:
|
|
49
|
+
return self.pop()
|
|
50
|
+
|
|
51
|
+
def items(self) -> List[V]:
|
|
52
|
+
return self._items
|
|
53
|
+
|
|
54
|
+
async def async_items(self) -> List[V]:
|
|
55
|
+
return self.items()
|
|
56
|
+
|
|
57
|
+
def length(self) -> int:
|
|
58
|
+
return len(self._items)
|
|
59
|
+
|
|
60
|
+
async def async_length(self) -> int:
|
|
61
|
+
return self.length()
|
|
62
|
+
|
|
63
|
+
def filter(self, predicate: Callable[[V, int], bool]) -> List[V]:
|
|
64
|
+
return [item for i, item in enumerate(self._items) if predicate(item, i)]
|
|
65
|
+
|
|
66
|
+
async def async_filter(self, predicate: Callable[[V, int], bool]) -> List[V]:
|
|
67
|
+
return self.filter(predicate)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, List, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
from .storage import Storage
|
|
11
|
+
|
|
12
|
+
V = TypeVar("V")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LocalStorageOptions:
|
|
17
|
+
max: Optional[int] = None
|
|
18
|
+
"""Maximum number of items in the storage"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalStorage(Storage[str, V]):
|
|
22
|
+
"""
|
|
23
|
+
A key-value storage with optional size limit and LRU behavior.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def store(self) -> OrderedDict[str, V]:
|
|
28
|
+
return self._store
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def options(self) -> LocalStorageOptions:
|
|
32
|
+
return self._options
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def keys(self) -> List[str]:
|
|
36
|
+
return list(self._store.keys())
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def size(self) -> int:
|
|
40
|
+
return len(self._store)
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
data: Optional[Dict[str, V]] = None,
|
|
45
|
+
options: Optional[LocalStorageOptions] = None,
|
|
46
|
+
):
|
|
47
|
+
self._store = OrderedDict(data or {})
|
|
48
|
+
self._options = options or LocalStorageOptions()
|
|
49
|
+
|
|
50
|
+
def get(self, key: str) -> Optional[V]:
|
|
51
|
+
if key not in self._store:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
value = self._store.pop(key)
|
|
55
|
+
self._store[key] = value
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
async def async_get(self, key: str) -> Optional[V]:
|
|
59
|
+
return self.get(key)
|
|
60
|
+
|
|
61
|
+
def set(self, key: str, value: V) -> None:
|
|
62
|
+
if key in self._store:
|
|
63
|
+
del self._store[key]
|
|
64
|
+
elif self._options.max and len(self._store) >= self._options.max:
|
|
65
|
+
self._store.popitem(last=False)
|
|
66
|
+
|
|
67
|
+
self._store[key] = value
|
|
68
|
+
|
|
69
|
+
async def async_set(self, key: str, value: V) -> None:
|
|
70
|
+
return self.set(key, value)
|
|
71
|
+
|
|
72
|
+
def delete(self, key: str) -> None:
|
|
73
|
+
if key in self._store:
|
|
74
|
+
del self._store[key]
|
|
75
|
+
|
|
76
|
+
async def async_delete(self, key: str) -> None:
|
|
77
|
+
return self.delete(key)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Callable, Generic, List, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
K = TypeVar("K")
|
|
10
|
+
V = TypeVar("V")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Storage(Generic[K, V], ABC):
|
|
14
|
+
"""A storage container that can get/set/delete items by a unique key."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def get(self, key: K) -> Optional[V]:
|
|
18
|
+
"""Synchronously get a value by key."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def async_get(self, key: K) -> Optional[V]:
|
|
23
|
+
"""Asynchronously get a value by key."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def set(self, key: K, value: V) -> None:
|
|
28
|
+
"""Synchronously set a value by key."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def async_set(self, key: K, value: V) -> None:
|
|
33
|
+
"""Asynchronously set a value by key."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def delete(self, key: K) -> None:
|
|
38
|
+
"""Synchronously delete a value by key."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def async_delete(self, key: K) -> None:
|
|
43
|
+
"""Asynchronously delete a value by key."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ListStorage(Storage[int, V], ABC):
|
|
48
|
+
"""A list storage container that can store/query iterable data."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def append(self, value: V) -> None:
|
|
52
|
+
"""Synchronously append a value to the list."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def async_append(self, value: V) -> None:
|
|
57
|
+
"""Asynchronously append a value to the list."""
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def pop(self) -> Optional[V]:
|
|
62
|
+
"""Synchronously remove and return the last value."""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def async_pop(self) -> Optional[V]:
|
|
67
|
+
"""Asynchronously remove and return the last value."""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def items(self) -> List[V]:
|
|
72
|
+
"""Synchronously return all items as a list."""
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def async_items(self) -> List[V]:
|
|
77
|
+
"""Asynchronously return all items as a list."""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def length(self) -> int:
|
|
82
|
+
"""Return the number of items."""
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def async_length(self) -> int:
|
|
87
|
+
"""Return the number of items."""
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def filter(self, predicate: Callable[[V, int], bool]) -> List[V]:
|
|
92
|
+
"""Synchronously filter items using predicate."""
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
async def async_filter(self, predicate: Callable[[V, int], bool]) -> List[V]:
|
|
97
|
+
"""Asynchronously filter items using predicate."""
|
|
98
|
+
pass
|