mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 mcp-ticketer might be problematic. Click here for more details.

Files changed (109) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +796 -46
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +879 -129
  11. mcp_ticketer/adapters/hybrid.py +11 -11
  12. mcp_ticketer/adapters/jira.py +973 -73
  13. mcp_ticketer/adapters/linear/__init__.py +24 -0
  14. mcp_ticketer/adapters/linear/adapter.py +2732 -0
  15. mcp_ticketer/adapters/linear/client.py +344 -0
  16. mcp_ticketer/adapters/linear/mappers.py +420 -0
  17. mcp_ticketer/adapters/linear/queries.py +479 -0
  18. mcp_ticketer/adapters/linear/types.py +360 -0
  19. mcp_ticketer/adapters/linear.py +10 -2315
  20. mcp_ticketer/analysis/__init__.py +23 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/similarity.py +224 -0
  23. mcp_ticketer/analysis/staleness.py +266 -0
  24. mcp_ticketer/cache/memory.py +9 -8
  25. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  26. mcp_ticketer/cli/auggie_configure.py +116 -15
  27. mcp_ticketer/cli/codex_configure.py +274 -82
  28. mcp_ticketer/cli/configure.py +888 -151
  29. mcp_ticketer/cli/diagnostics.py +400 -157
  30. mcp_ticketer/cli/discover.py +297 -26
  31. mcp_ticketer/cli/gemini_configure.py +119 -26
  32. mcp_ticketer/cli/init_command.py +880 -0
  33. mcp_ticketer/cli/instruction_commands.py +435 -0
  34. mcp_ticketer/cli/linear_commands.py +616 -0
  35. mcp_ticketer/cli/main.py +203 -1165
  36. mcp_ticketer/cli/mcp_configure.py +474 -90
  37. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  38. mcp_ticketer/cli/migrate_config.py +12 -8
  39. mcp_ticketer/cli/platform_commands.py +123 -0
  40. mcp_ticketer/cli/platform_detection.py +418 -0
  41. mcp_ticketer/cli/platform_installer.py +513 -0
  42. mcp_ticketer/cli/python_detection.py +126 -0
  43. mcp_ticketer/cli/queue_commands.py +15 -15
  44. mcp_ticketer/cli/setup_command.py +639 -0
  45. mcp_ticketer/cli/simple_health.py +90 -65
  46. mcp_ticketer/cli/ticket_commands.py +1013 -0
  47. mcp_ticketer/cli/update_checker.py +313 -0
  48. mcp_ticketer/cli/utils.py +114 -66
  49. mcp_ticketer/core/__init__.py +24 -1
  50. mcp_ticketer/core/adapter.py +250 -16
  51. mcp_ticketer/core/config.py +145 -37
  52. mcp_ticketer/core/env_discovery.py +101 -22
  53. mcp_ticketer/core/env_loader.py +349 -0
  54. mcp_ticketer/core/exceptions.py +160 -0
  55. mcp_ticketer/core/http_client.py +26 -26
  56. mcp_ticketer/core/instructions.py +405 -0
  57. mcp_ticketer/core/label_manager.py +732 -0
  58. mcp_ticketer/core/mappers.py +42 -30
  59. mcp_ticketer/core/models.py +280 -28
  60. mcp_ticketer/core/onepassword_secrets.py +379 -0
  61. mcp_ticketer/core/project_config.py +183 -49
  62. mcp_ticketer/core/registry.py +3 -3
  63. mcp_ticketer/core/session_state.py +171 -0
  64. mcp_ticketer/core/state_matcher.py +592 -0
  65. mcp_ticketer/core/url_parser.py +425 -0
  66. mcp_ticketer/core/validators.py +69 -0
  67. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  68. mcp_ticketer/mcp/__init__.py +29 -1
  69. mcp_ticketer/mcp/__main__.py +60 -0
  70. mcp_ticketer/mcp/server/__init__.py +25 -0
  71. mcp_ticketer/mcp/server/__main__.py +60 -0
  72. mcp_ticketer/mcp/server/constants.py +58 -0
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/dto.py +195 -0
  75. mcp_ticketer/mcp/server/main.py +1343 -0
  76. mcp_ticketer/mcp/server/response_builder.py +206 -0
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +56 -0
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
  90. mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
  91. mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
  92. mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
  93. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
  94. mcp_ticketer/queue/__init__.py +1 -0
  95. mcp_ticketer/queue/health_monitor.py +168 -136
  96. mcp_ticketer/queue/manager.py +95 -25
  97. mcp_ticketer/queue/queue.py +40 -21
  98. mcp_ticketer/queue/run_worker.py +6 -1
  99. mcp_ticketer/queue/ticket_registry.py +213 -155
  100. mcp_ticketer/queue/worker.py +109 -49
  101. mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
  102. mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
  103. mcp_ticketer/mcp/server.py +0 -1895
  104. mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
  105. mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
  106. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
  107. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
  108. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
  109. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env python3
2
+ """Unified Environment Loading System for MCP Ticketer.
3
+
4
+ This module provides a resilient environment loading system that:
5
+ 1. Supports multiple naming conventions for each configuration key
6
+ 2. Loads from multiple sources (.env.local, .env, environment variables)
7
+ 3. Works consistently across CLI, worker processes, and MCP server
8
+ 4. Provides fallback mechanisms for different key aliases
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class EnvKeyConfig:
22
+ """Configuration for environment variable key aliases."""
23
+
24
+ primary_key: str
25
+ aliases: list[str]
26
+ description: str
27
+ required: bool = False
28
+ default: str | None = None
29
+
30
+
31
+ class UnifiedEnvLoader:
32
+ """Unified environment loader that handles multiple naming conventions.
33
+
34
+ Provides consistent environment loading across all contexts.
35
+ """
36
+
37
+ # Define key aliases for all adapters
38
+ KEY_MAPPINGS = {
39
+ # Linear adapter keys
40
+ "linear_api_key": EnvKeyConfig(
41
+ primary_key="LINEAR_API_KEY",
42
+ aliases=["LINEAR_TOKEN", "LINEAR_ACCESS_TOKEN", "LINEAR_AUTH_TOKEN"],
43
+ description="Linear API key",
44
+ required=True,
45
+ ),
46
+ "linear_team_id": EnvKeyConfig(
47
+ primary_key="LINEAR_TEAM_ID",
48
+ aliases=["LINEAR_TEAM_UUID", "LINEAR_TEAM_IDENTIFIER"],
49
+ description="Linear team ID (UUID)",
50
+ required=False,
51
+ ),
52
+ "linear_team_key": EnvKeyConfig(
53
+ primary_key="LINEAR_TEAM_KEY",
54
+ aliases=["LINEAR_TEAM_IDENTIFIER", "LINEAR_TEAM_NAME"],
55
+ description="Linear team key (short name)",
56
+ required=False,
57
+ ),
58
+ # JIRA adapter keys
59
+ "jira_server": EnvKeyConfig(
60
+ primary_key="JIRA_SERVER",
61
+ aliases=["JIRA_URL", "JIRA_HOST", "JIRA_BASE_URL"],
62
+ description="JIRA server URL",
63
+ required=True,
64
+ ),
65
+ "jira_email": EnvKeyConfig(
66
+ primary_key="JIRA_EMAIL",
67
+ aliases=["JIRA_USER", "JIRA_USERNAME", "JIRA_ACCESS_USER"],
68
+ description="JIRA user email",
69
+ required=True,
70
+ ),
71
+ "jira_api_token": EnvKeyConfig(
72
+ primary_key="JIRA_API_TOKEN",
73
+ aliases=[
74
+ "JIRA_TOKEN",
75
+ "JIRA_ACCESS_TOKEN",
76
+ "JIRA_AUTH_TOKEN",
77
+ "JIRA_PASSWORD",
78
+ ],
79
+ description="JIRA API token",
80
+ required=True,
81
+ ),
82
+ "jira_project_key": EnvKeyConfig(
83
+ primary_key="JIRA_PROJECT_KEY",
84
+ aliases=["JIRA_PROJECT", "JIRA_PROJECT_ID"],
85
+ description="JIRA project key",
86
+ required=False,
87
+ ),
88
+ # GitHub adapter keys
89
+ "github_token": EnvKeyConfig(
90
+ primary_key="GITHUB_TOKEN",
91
+ aliases=["GITHUB_ACCESS_TOKEN", "GITHUB_API_TOKEN", "GITHUB_AUTH_TOKEN"],
92
+ description="GitHub access token",
93
+ required=True,
94
+ ),
95
+ "github_owner": EnvKeyConfig(
96
+ primary_key="GITHUB_OWNER",
97
+ aliases=["GITHUB_USER", "GITHUB_USERNAME", "GITHUB_ORG"],
98
+ description="GitHub repository owner",
99
+ required=True,
100
+ ),
101
+ "github_repo": EnvKeyConfig(
102
+ primary_key="GITHUB_REPO",
103
+ aliases=["GITHUB_REPOSITORY", "GITHUB_REPO_NAME"],
104
+ description="GitHub repository name",
105
+ required=True,
106
+ ),
107
+ }
108
+
109
+ def __init__(self, project_root: Path | None = None):
110
+ """Initialize the environment loader.
111
+
112
+ Args:
113
+ project_root: Project root directory. If None, will auto-detect.
114
+
115
+ """
116
+ self.project_root = project_root or self._find_project_root()
117
+ self._env_cache: dict[str, str] = {}
118
+ self._load_env_files()
119
+
120
+ def _find_project_root(self) -> Path:
121
+ """Find the project root directory."""
122
+ current = Path.cwd()
123
+
124
+ # Look for common project indicators
125
+ indicators = [".mcp-ticketer", ".git", "pyproject.toml", "setup.py"]
126
+
127
+ while current != current.parent:
128
+ if any((current / indicator).exists() for indicator in indicators):
129
+ return current
130
+ current = current.parent
131
+
132
+ # Fallback to current directory
133
+ return Path.cwd()
134
+
135
+ def _load_env_files(self) -> None:
136
+ """Load environment variables from .env files."""
137
+ env_files = [
138
+ self.project_root / ".env.local",
139
+ self.project_root / ".env",
140
+ Path.home() / ".mcp-ticketer" / ".env",
141
+ ]
142
+
143
+ for env_file in env_files:
144
+ if env_file.exists():
145
+ logger.debug(f"Loading environment from: {env_file}")
146
+ self._load_env_file(env_file)
147
+
148
+ def _load_env_file(self, env_file: Path) -> None:
149
+ """Load variables from a single .env file."""
150
+ try:
151
+ with open(env_file) as f:
152
+ for _line_num, line in enumerate(f, 1):
153
+ line = line.strip()
154
+
155
+ # Skip empty lines and comments
156
+ if not line or line.startswith("#"):
157
+ continue
158
+
159
+ # Parse KEY=VALUE format
160
+ if "=" in line:
161
+ key, value = line.split("=", 1)
162
+ key = key.strip()
163
+ value = value.strip()
164
+
165
+ # Remove quotes if present
166
+ if value.startswith('"') and value.endswith('"'):
167
+ value = value[1:-1]
168
+ elif value.startswith("'") and value.endswith("'"):
169
+ value = value[1:-1]
170
+
171
+ # Only set if not already in environment
172
+ if key not in os.environ:
173
+ os.environ[key] = value
174
+ self._env_cache[key] = value
175
+ logger.debug(f"Loaded {key} from {env_file}")
176
+
177
+ except Exception as e:
178
+ logger.warning(f"Failed to load {env_file}: {e}")
179
+
180
+ def get_value(
181
+ self, config_key: str, config: dict[str, Any] | None = None
182
+ ) -> str | None:
183
+ """Get a configuration value using the key alias system.
184
+
185
+ Args:
186
+ config_key: The configuration key (e.g., 'linear_api_key')
187
+ config: Optional configuration dictionary to check first
188
+
189
+ Returns:
190
+ The value if found, None otherwise
191
+
192
+ """
193
+ if config_key not in self.KEY_MAPPINGS:
194
+ logger.warning(f"Unknown configuration key: {config_key}")
195
+ return None
196
+
197
+ key_config = self.KEY_MAPPINGS[config_key]
198
+
199
+ # 1. Check provided config dictionary first
200
+ if config:
201
+ # Check for the config key itself (without adapter prefix)
202
+ simple_key = (
203
+ config_key.split("_", 1)[1] if "_" in config_key else config_key
204
+ )
205
+ if simple_key in config:
206
+ value = config[simple_key]
207
+ if value:
208
+ logger.debug(f"Found {config_key} in config as {simple_key}")
209
+ return str(value)
210
+
211
+ # 2. Check environment variables (primary key first, then aliases)
212
+ all_keys = [key_config.primary_key] + key_config.aliases
213
+
214
+ for env_key in all_keys:
215
+ value = os.getenv(env_key)
216
+ if value:
217
+ logger.debug(f"Found {config_key} as {env_key}")
218
+ return value
219
+
220
+ # 3. Return default if available
221
+ if key_config.default:
222
+ logger.debug(f"Using default for {config_key}")
223
+ return key_config.default
224
+
225
+ # 4. Log if required key is missing
226
+ if key_config.required:
227
+ logger.warning(
228
+ f"Required configuration key {config_key} not found. Tried: {all_keys}"
229
+ )
230
+
231
+ return None
232
+
233
+ def get_adapter_config(
234
+ self, adapter_name: str, base_config: dict[str, Any] | None = None
235
+ ) -> dict[str, Any]:
236
+ """Get complete configuration for an adapter with environment variable resolution.
237
+
238
+ Args:
239
+ adapter_name: Name of the adapter ('linear', 'jira', 'github')
240
+ base_config: Base configuration dictionary
241
+
242
+ Returns:
243
+ Complete configuration with environment variables resolved
244
+
245
+ """
246
+ config = base_config.copy() if base_config else {}
247
+
248
+ # Get adapter-specific keys
249
+ adapter_keys = [
250
+ key
251
+ for key in self.KEY_MAPPINGS.keys()
252
+ if key.startswith(f"{adapter_name}_")
253
+ ]
254
+
255
+ for config_key in adapter_keys:
256
+ # Remove adapter prefix for the config key
257
+ simple_key = config_key.split("_", 1)[1]
258
+
259
+ # Only set if not already in config or if config value is empty
260
+ if simple_key not in config or not config[simple_key]:
261
+ value = self.get_value(config_key, config)
262
+ if value:
263
+ config[simple_key] = value
264
+
265
+ return config
266
+
267
+ def validate_adapter_config(
268
+ self, adapter_name: str, config: dict[str, Any]
269
+ ) -> list[str]:
270
+ """Validate that all required configuration is present for an adapter.
271
+
272
+ Args:
273
+ adapter_name: Name of the adapter
274
+ config: Configuration dictionary
275
+
276
+ Returns:
277
+ List of missing required keys (empty if all required keys are present)
278
+
279
+ """
280
+ missing_keys = []
281
+ adapter_keys = [
282
+ key
283
+ for key in self.KEY_MAPPINGS.keys()
284
+ if key.startswith(f"{adapter_name}_")
285
+ ]
286
+
287
+ for config_key in adapter_keys:
288
+ key_config = self.KEY_MAPPINGS[config_key]
289
+ if key_config.required:
290
+ simple_key = config_key.split("_", 1)[1]
291
+ if simple_key not in config or not config[simple_key]:
292
+ missing_keys.append(f"{simple_key} ({key_config.description})")
293
+
294
+ return missing_keys
295
+
296
+ def get_debug_info(self) -> dict[str, Any]:
297
+ """Get debug information about environment loading."""
298
+ return {
299
+ "project_root": str(self.project_root),
300
+ "env_files_checked": [
301
+ str(self.project_root / ".env.local"),
302
+ str(self.project_root / ".env"),
303
+ str(Path.home() / ".mcp-ticketer" / ".env"),
304
+ ],
305
+ "loaded_keys": list(self._env_cache.keys()),
306
+ "available_configs": list(self.KEY_MAPPINGS.keys()),
307
+ }
308
+
309
+
310
+ # Global instance
311
+ _env_loader: UnifiedEnvLoader | None = None
312
+
313
+
314
+ def get_env_loader() -> UnifiedEnvLoader:
315
+ """Get the global environment loader instance."""
316
+ global _env_loader
317
+ if _env_loader is None:
318
+ _env_loader = UnifiedEnvLoader()
319
+ return _env_loader
320
+
321
+
322
+ def load_adapter_config(
323
+ adapter_name: str, base_config: dict[str, Any] | None = None
324
+ ) -> dict[str, Any]:
325
+ """Load adapter configuration with environment variables.
326
+
327
+ Args:
328
+ adapter_name: Name of the adapter ('linear', 'jira', 'github')
329
+ base_config: Base configuration dictionary
330
+
331
+ Returns:
332
+ Complete configuration with environment variables resolved
333
+
334
+ """
335
+ return get_env_loader().get_adapter_config(adapter_name, base_config)
336
+
337
+
338
+ def validate_adapter_config(adapter_name: str, config: dict[str, Any]) -> list[str]:
339
+ """Validate adapter configuration.
340
+
341
+ Args:
342
+ adapter_name: Name of the adapter
343
+ config: Configuration dictionary
344
+
345
+ Returns:
346
+ List of missing required keys (empty if all required keys are present)
347
+
348
+ """
349
+ return get_env_loader().validate_adapter_config(adapter_name, config)
@@ -0,0 +1,160 @@
1
+ """Exception classes for MCP Ticketer.
2
+
3
+ Error Severity Classification:
4
+ CRITICAL - System-level issues (auth, config, network) → Always suggest diagnostics
5
+ MEDIUM - Resource issues (not found, permissions) → Suggest diagnostics
6
+ LOW - User input errors (validation, state transitions) → No diagnostics
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from .models import TicketState
14
+
15
+
16
+ class MCPTicketerError(Exception):
17
+ """Base exception for MCP Ticketer."""
18
+
19
+ pass
20
+
21
+
22
+ class AdapterError(MCPTicketerError):
23
+ """Base adapter error."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ adapter_name: str,
29
+ original_error: Exception | None = None,
30
+ ):
31
+ """Initialize adapter error.
32
+
33
+ Args:
34
+ message: Error message
35
+ adapter_name: Name of the adapter that raised the error
36
+ original_error: Original exception that caused this error
37
+
38
+ """
39
+ super().__init__(message)
40
+ self.adapter_name = adapter_name
41
+ self.original_error = original_error
42
+
43
+ def __str__(self) -> str:
44
+ """Return string representation of the error."""
45
+ base_msg = f"[{self.adapter_name}] {super().__str__()}"
46
+ if self.original_error:
47
+ base_msg += f" (caused by: {self.original_error})"
48
+ return base_msg
49
+
50
+
51
+ class AuthenticationError(AdapterError):
52
+ """Authentication failed with external service."""
53
+
54
+ pass
55
+
56
+
57
+ class RateLimitError(AdapterError):
58
+ """Rate limit exceeded."""
59
+
60
+ def __init__(
61
+ self,
62
+ message: str,
63
+ adapter_name: str,
64
+ retry_after: int | None = None,
65
+ original_error: Exception | None = None,
66
+ ):
67
+ """Initialize rate limit error.
68
+
69
+ Args:
70
+ message: Error message
71
+ adapter_name: Name of the adapter
72
+ retry_after: Seconds to wait before retrying
73
+ original_error: Original exception
74
+
75
+ """
76
+ super().__init__(message, adapter_name, original_error)
77
+ self.retry_after = retry_after
78
+
79
+
80
+ class ValidationError(MCPTicketerError):
81
+ """Data validation error."""
82
+
83
+ def __init__(self, message: str, field: str | None = None, value: Any = None):
84
+ """Initialize validation error.
85
+
86
+ Args:
87
+ message: Error message
88
+ field: Field that failed validation
89
+ value: Value that failed validation
90
+
91
+ """
92
+ super().__init__(message)
93
+ self.field = field
94
+ self.value = value
95
+
96
+ def __str__(self) -> str:
97
+ """Return string representation of the error."""
98
+ base_msg = super().__str__()
99
+ if self.field:
100
+ base_msg += f" (field: {self.field})"
101
+ if self.value is not None:
102
+ base_msg += f" (value: {self.value})"
103
+ return base_msg
104
+
105
+
106
+ class ConfigurationError(MCPTicketerError):
107
+ """Configuration error."""
108
+
109
+ pass
110
+
111
+
112
+ class CacheError(MCPTicketerError):
113
+ """Cache operation error."""
114
+
115
+ pass
116
+
117
+
118
+ class StateTransitionError(MCPTicketerError):
119
+ """Invalid state transition."""
120
+
121
+ def __init__(self, message: str, from_state: TicketState, to_state: TicketState):
122
+ """Initialize state transition error.
123
+
124
+ Args:
125
+ message: Error message
126
+ from_state: Current state
127
+ to_state: Target state
128
+
129
+ """
130
+ super().__init__(message)
131
+ self.from_state = from_state
132
+ self.to_state = to_state
133
+
134
+ def __str__(self) -> str:
135
+ """Return string representation of the error."""
136
+ return f"{super().__str__()} ({self.from_state} -> {self.to_state})"
137
+
138
+
139
+ class NetworkError(AdapterError):
140
+ """Network-related error."""
141
+
142
+ pass
143
+
144
+
145
+ class TimeoutError(AdapterError):
146
+ """Request timeout error."""
147
+
148
+ pass
149
+
150
+
151
+ class NotFoundError(AdapterError):
152
+ """Resource not found error."""
153
+
154
+ pass
155
+
156
+
157
+ class PermissionError(AdapterError):
158
+ """Permission denied error."""
159
+
160
+ pass
@@ -4,7 +4,7 @@ import asyncio
4
4
  import logging
5
5
  import time
6
6
  from enum import Enum
7
- from typing import Any, Optional, Union
7
+ from typing import Any
8
8
 
9
9
  import httpx
10
10
  from httpx import AsyncClient, TimeoutException
@@ -32,8 +32,8 @@ class RetryConfig:
32
32
  max_delay: float = 60.0,
33
33
  exponential_base: float = 2.0,
34
34
  jitter: bool = True,
35
- retry_on_status: Optional[list[int]] = None,
36
- retry_on_exceptions: Optional[list[type]] = None,
35
+ retry_on_status: list[int] | None = None,
36
+ retry_on_exceptions: list[type] | None = None,
37
37
  ):
38
38
  self.max_retries = max_retries
39
39
  self.initial_delay = initial_delay
@@ -94,11 +94,11 @@ class BaseHTTPClient:
94
94
  def __init__(
95
95
  self,
96
96
  base_url: str,
97
- headers: Optional[dict[str, str]] = None,
98
- auth: Optional[Union[httpx.Auth, tuple]] = None,
97
+ headers: dict[str, str] | None = None,
98
+ auth: httpx.Auth | tuple | None = None,
99
99
  timeout: float = 30.0,
100
- retry_config: Optional[RetryConfig] = None,
101
- rate_limiter: Optional[RateLimiter] = None,
100
+ retry_config: RetryConfig | None = None,
101
+ rate_limiter: RateLimiter | None = None,
102
102
  verify_ssl: bool = True,
103
103
  follow_redirects: bool = True,
104
104
  ):
@@ -132,7 +132,7 @@ class BaseHTTPClient:
132
132
  "errors": 0,
133
133
  }
134
134
 
135
- self._client: Optional[AsyncClient] = None
135
+ self._client: AsyncClient | None = None
136
136
 
137
137
  async def _get_client(self) -> AsyncClient:
138
138
  """Get or create HTTP client instance."""
@@ -148,7 +148,7 @@ class BaseHTTPClient:
148
148
  return self._client
149
149
 
150
150
  async def _calculate_delay(
151
- self, attempt: int, response: Optional[httpx.Response] = None
151
+ self, attempt: int, response: httpx.Response | None = None
152
152
  ) -> float:
153
153
  """Calculate delay for retry attempt."""
154
154
  if response and response.status_code == 429:
@@ -178,7 +178,7 @@ class BaseHTTPClient:
178
178
  def _should_retry(
179
179
  self,
180
180
  exception: Exception,
181
- response: Optional[httpx.Response] = None,
181
+ response: httpx.Response | None = None,
182
182
  attempt: int = 1,
183
183
  ) -> bool:
184
184
  """Determine if request should be retried."""
@@ -198,15 +198,15 @@ class BaseHTTPClient:
198
198
 
199
199
  async def request(
200
200
  self,
201
- method: Union[HTTPMethod, str],
201
+ method: HTTPMethod | str,
202
202
  endpoint: str,
203
- data: Optional[dict[str, Any]] = None,
204
- json: Optional[dict[str, Any]] = None,
205
- params: Optional[dict[str, Any]] = None,
206
- headers: Optional[dict[str, str]] = None,
207
- timeout: Optional[float] = None,
203
+ data: dict[str, Any] | None = None,
204
+ json: dict[str, Any] | None = None,
205
+ params: dict[str, Any] | None = None,
206
+ headers: dict[str, str] | None = None,
207
+ timeout: float | None = None,
208
208
  retry_count: int = 0,
209
- **kwargs,
209
+ **kwargs: Any,
210
210
  ) -> httpx.Response:
211
211
  """Make HTTP request with retry and rate limiting.
212
212
 
@@ -293,27 +293,27 @@ class BaseHTTPClient:
293
293
  # No more retries, re-raise the exception
294
294
  raise
295
295
 
296
- async def get(self, endpoint: str, **kwargs) -> httpx.Response:
296
+ async def get(self, endpoint: str, **kwargs: Any) -> httpx.Response:
297
297
  """Make GET request."""
298
298
  return await self.request(HTTPMethod.GET, endpoint, **kwargs)
299
299
 
300
- async def post(self, endpoint: str, **kwargs) -> httpx.Response:
300
+ async def post(self, endpoint: str, **kwargs: Any) -> httpx.Response:
301
301
  """Make POST request."""
302
302
  return await self.request(HTTPMethod.POST, endpoint, **kwargs)
303
303
 
304
- async def put(self, endpoint: str, **kwargs) -> httpx.Response:
304
+ async def put(self, endpoint: str, **kwargs: Any) -> httpx.Response:
305
305
  """Make PUT request."""
306
306
  return await self.request(HTTPMethod.PUT, endpoint, **kwargs)
307
307
 
308
- async def patch(self, endpoint: str, **kwargs) -> httpx.Response:
308
+ async def patch(self, endpoint: str, **kwargs: Any) -> httpx.Response:
309
309
  """Make PATCH request."""
310
310
  return await self.request(HTTPMethod.PATCH, endpoint, **kwargs)
311
311
 
312
- async def delete(self, endpoint: str, **kwargs) -> httpx.Response:
312
+ async def delete(self, endpoint: str, **kwargs: Any) -> httpx.Response:
313
313
  """Make DELETE request."""
314
314
  return await self.request(HTTPMethod.DELETE, endpoint, **kwargs)
315
315
 
316
- async def get_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
316
+ async def get_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
317
317
  """Make GET request and return JSON response."""
318
318
  response = await self.get(endpoint, **kwargs)
319
319
 
@@ -323,7 +323,7 @@ class BaseHTTPClient:
323
323
 
324
324
  return response.json()
325
325
 
326
- async def post_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
326
+ async def post_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
327
327
  """Make POST request and return JSON response."""
328
328
  response = await self.post(endpoint, **kwargs)
329
329
 
@@ -333,7 +333,7 @@ class BaseHTTPClient:
333
333
 
334
334
  return response.json()
335
335
 
336
- async def put_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
336
+ async def put_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
337
337
  """Make PUT request and return JSON response."""
338
338
  response = await self.put(endpoint, **kwargs)
339
339
 
@@ -343,7 +343,7 @@ class BaseHTTPClient:
343
343
 
344
344
  return response.json()
345
345
 
346
- async def patch_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
346
+ async def patch_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
347
347
  """Make PATCH request and return JSON response."""
348
348
  response = await self.patch(endpoint, **kwargs)
349
349