hn-deepocr-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ from hn_deepocr_client.auth import create_token
2
+ from hn_deepocr_client.config import NetworkConfig
3
+ from hn_deepocr_client.exceptions import (
4
+ AuthenticationError,
5
+ ConfigurationError,
6
+ HNDeepOCRError,
7
+ ParseError,
8
+ UploadError,
9
+ )
10
+ from hn_deepocr_client.parser import ParserPDF
11
+ from hn_deepocr_client.settings import Settings, load_config
12
+
13
+ __all__ = [
14
+ "Settings",
15
+ "load_config",
16
+ "NetworkConfig",
17
+ "ParserPDF",
18
+ "create_token",
19
+ "HNDeepOCRError",
20
+ "AuthenticationError",
21
+ "UploadError",
22
+ "ParseError",
23
+ "ConfigurationError",
24
+ ]
25
+
26
+ __version__ = "0.1.0"
@@ -0,0 +1,58 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import time
5
+ import urllib.parse as uri_utils
6
+
7
+
8
+ def sign(sign_key: str, base_64_parameters: str, uri, request_body: str = "") -> str:
9
+ """签名生成
10
+
11
+ Args:
12
+ sign_key: 签名密钥
13
+ base_64_parameters: Base64 编码的参数
14
+ uri: 请求 URI
15
+ request_body: 请求体,默认为空字符串
16
+
17
+ Returns:
18
+ 签名字符串(大写)
19
+ """
20
+ data = base_64_parameters + get_relative_url(uri) + request_body
21
+ md5 = hashlib.md5((data + sign_key).encode("utf-8"))
22
+ return md5.hexdigest().upper()
23
+
24
+
25
+ def get_relative_url(uri) -> str:
26
+ """获取相对 URL
27
+
28
+ Args:
29
+ uri: 解析后的 URI 对象
30
+
31
+ Returns:
32
+ 相对路径(包含查询参数,如果存在)
33
+ """
34
+ if uri.query is None or len(uri.query) == 0:
35
+ return uri.path
36
+ return uri.path + "?" + uri.query
37
+
38
+
39
+ def create_token(ak: str, sk: str, url: str = None, request_body: str = "") -> str:
40
+ """创建认证 Token
41
+
42
+ Args:
43
+ ak: Access Key
44
+ sk: Secret Key
45
+ url: 完整请求 URL
46
+ request_body: 请求体,默认为空字符串
47
+
48
+ Returns:
49
+ 认证 Token,格式为:base64_parameters.signature
50
+ """
51
+ uri = uri_utils.urlsplit(url, allow_fragments=False)
52
+ current_time = round(time.time() * 1000)
53
+ parameters = json.dumps(
54
+ {"alg": "MD5", "appKey": ak, "timestamp": str(current_time)}, separators=(",", ":")
55
+ )
56
+ base64_parameters = str(base64.b64encode(bytes(parameters, "utf-8")), "utf-8")
57
+ signature = sign(sk, base64_parameters, uri, request_body)
58
+ return base64_parameters + "." + signature
@@ -0,0 +1,35 @@
1
+ """Configuration management for hn-deepocr-client."""
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class NetworkConfig(BaseSettings):
7
+ """Network configuration for HTTP client."""
8
+
9
+ # Timeout configurations (seconds)
10
+ timeout_api: int = 30
11
+ timeout_upload: int = 300
12
+ timeout_status: int = 10
13
+ timeout_merge: int = 60
14
+
15
+ # Retry configurations
16
+ max_retries_default: int = 3
17
+ max_retries_upload: int = 5
18
+ base_delay: float = 1.0
19
+
20
+ # Connection pool configurations
21
+ connection_pool_size: int = 10
22
+ connection_maxsize: int = 20
23
+
24
+ # File partition configuration (bytes)
25
+ part_size: int = 10 * 1024 * 1024 # 10MB default
26
+
27
+ # SSL verification
28
+ verify_ssl: bool = True
29
+
30
+ model_config = SettingsConfigDict(
31
+ env_file=".env",
32
+ env_file_encoding="utf-8",
33
+ env_prefix="HN_DEEP_OCR_",
34
+ extra="ignore",
35
+ )
@@ -0,0 +1,31 @@
1
+ """自定义异常类"""
2
+
3
+
4
+ class HNDeepOCRError(Exception):
5
+ """基础异常类"""
6
+
7
+ pass
8
+
9
+
10
+ class AuthenticationError(HNDeepOCRError):
11
+ """认证相关异常"""
12
+
13
+ pass
14
+
15
+
16
+ class UploadError(HNDeepOCRError):
17
+ """文件上传相关异常"""
18
+
19
+ pass
20
+
21
+
22
+ class ParseError(HNDeepOCRError):
23
+ """文件解析相关异常"""
24
+
25
+ pass
26
+
27
+
28
+ class ConfigurationError(HNDeepOCRError):
29
+ """配置相关异常"""
30
+
31
+ pass
@@ -0,0 +1,295 @@
1
+ """HTTP client for hn-deepocr-client with connection pooling and retry logic."""
2
+
3
+ import json
4
+ import logging
5
+ import random
6
+ import time
7
+ from typing import Any
8
+
9
+ import requests
10
+ from requests.adapters import HTTPAdapter
11
+ from requests.exceptions import ConnectionError, HTTPError, Timeout, TooManyRedirects
12
+
13
+ from hn_deepocr_client.config import NetworkConfig
14
+ from hn_deepocr_client.exceptions import AuthenticationError, ParseError, UploadError
15
+
16
+
17
+ class HTTPClient:
18
+ """HTTP client with connection pooling, retry logic, and standardized error handling."""
19
+
20
+ def __init__(self, config: NetworkConfig, session: requests.Session | None = None):
21
+ """Initialize HTTP client.
22
+
23
+ Args:
24
+ config: Network configuration
25
+ session: Optional existing session to use
26
+ """
27
+ self.config = config
28
+ self._logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
29
+
30
+ self._session = session or self._create_session()
31
+ self._owns_session = session is None
32
+
33
+ def _create_session(self) -> requests.Session:
34
+ """Create and configure a requests session with connection pooling."""
35
+ session = requests.Session()
36
+
37
+ adapter = HTTPAdapter(
38
+ pool_connections=self.config.connection_pool_size,
39
+ pool_maxsize=self.config.connection_maxsize,
40
+ max_retries=0,
41
+ pool_block=False,
42
+ )
43
+ session.mount("http://", adapter)
44
+ session.mount("https://", adapter)
45
+
46
+ session.headers.update(
47
+ {
48
+ "User-Agent": "hn-deepocr-client/1.0",
49
+ "Accept": "application/json",
50
+ }
51
+ )
52
+
53
+ return session
54
+
55
+ def __enter__(self):
56
+ """Context manager entry."""
57
+ return self
58
+
59
+ def __exit__(self, exc_type, exc_val, exc_tb):
60
+ """Context manager exit - close session if we own it."""
61
+ if self._owns_session:
62
+ self._session.close()
63
+
64
+ def close(self):
65
+ """Explicitly close the session."""
66
+ if self._owns_session:
67
+ self._session.close()
68
+
69
+ def _exponential_backoff_delay(self, attempt: int) -> float:
70
+ """Calculate exponential backoff delay with jitter.
71
+
72
+ Args:
73
+ attempt: Current attempt number (1-based)
74
+
75
+ Returns:
76
+ Delay in seconds, guaranteed non-negative
77
+ """
78
+ delay = min(self.config.base_delay * (2 ** (attempt - 1)), 60.0)
79
+ jitter = random.uniform(-0.25, 0.25)
80
+ return max(0.0, delay * (1 + jitter))
81
+
82
+ def _is_retryable_error(self, error: Exception) -> bool:
83
+ """Determine if an error is retryable."""
84
+ if isinstance(error, (ConnectionError, Timeout, TooManyRedirects)):
85
+ return True
86
+ if isinstance(error, HTTPError):
87
+ return 500 <= error.response.status_code < 600
88
+ return False
89
+
90
+ def _handle_api_error(self, response: requests.Response, operation: str) -> None:
91
+ """Handle API-specific errors with proper categorization.
92
+
93
+ Raises:
94
+ AuthenticationError: 401/403 responses
95
+ UploadError: 4xx client errors (except 401/403/404)
96
+ ParseError: 5xx server errors and other HTTP errors
97
+ """
98
+ try:
99
+ response.raise_for_status()
100
+ except HTTPError as e:
101
+ status_code = response.status_code
102
+ if status_code == 401:
103
+ raise AuthenticationError(f"Authentication failed for {operation}: {e}") from e
104
+ elif status_code == 403:
105
+ raise AuthenticationError(f"Access denied for {operation}: {e}") from e
106
+ elif status_code == 404:
107
+ raise UploadError(f"Endpoint not found for {operation}: {e}") from e
108
+ elif 400 <= status_code < 500:
109
+ raise UploadError(f"Client error {status_code} for {operation}: {e}") from e
110
+ elif 500 <= status_code < 600:
111
+ raise ParseError(f"Server error {status_code} for {operation}: {e}") from e
112
+ else:
113
+ raise ParseError(f"HTTP error {status_code} for {operation}: {e}") from e
114
+
115
+ def _retry_on_failure(
116
+ self,
117
+ func,
118
+ max_retries: int,
119
+ retryable_errors: set[type[BaseException]] | None = None,
120
+ operation_name: str = "",
121
+ ):
122
+ """Generic retry mechanism with exponential backoff.
123
+
124
+ Only retries on network-level errors (ConnectionError, Timeout) and
125
+ 5xx server errors. 4xx client errors (via AuthenticationError, UploadError)
126
+ are never retried.
127
+
128
+ Args:
129
+ func: Callable to retry
130
+ max_retries: Maximum number of attempts (must be >= 1)
131
+ retryable_errors: Set of retryable exception types
132
+ operation_name: Name for logging
133
+
134
+ Raises:
135
+ ValueError: If max_retries < 1
136
+ The last exception if all retries exhausted
137
+ """
138
+ if max_retries < 1:
139
+ raise ValueError(f"max_retries must be >= 1, got {max_retries}")
140
+
141
+ if retryable_errors is None:
142
+ retryable_errors = {ConnectionError, Timeout, ParseError, HTTPError}
143
+
144
+ last_exception: Exception | None = None
145
+
146
+ for attempt in range(1, max_retries + 1):
147
+ try:
148
+ result = func()
149
+ return result
150
+ except Exception as e:
151
+ last_exception = e
152
+
153
+ if not any(isinstance(e, error_type) for error_type in retryable_errors):
154
+ self._logger.warning(f"Non-retryable error in {operation_name}: {str(e)}")
155
+ raise
156
+
157
+ if attempt == max_retries:
158
+ self._logger.error(
159
+ f"Max retries exceeded for {operation_name}: {str(e)}",
160
+ exc_info=True,
161
+ )
162
+ break
163
+
164
+ delay = self._exponential_backoff_delay(attempt)
165
+ self._logger.warning(
166
+ f"Attempt {attempt}/{max_retries} failed for {operation_name}, "
167
+ f"retrying in {delay:.2f}s: {str(e)}"
168
+ )
169
+ time.sleep(delay)
170
+
171
+ raise last_exception
172
+
173
+ def make_request(
174
+ self,
175
+ method: str,
176
+ url: str,
177
+ timeout_key: str = "api",
178
+ **kwargs,
179
+ ) -> requests.Response:
180
+ """Make HTTP request using the configured session."""
181
+ timeout_map = {
182
+ "api": self.config.timeout_api,
183
+ "upload": self.config.timeout_upload,
184
+ "status": self.config.timeout_status,
185
+ "merge": self.config.timeout_merge,
186
+ }
187
+ timeout = timeout_map.get(timeout_key, self.config.timeout_api)
188
+
189
+ if "timeout" not in kwargs:
190
+ kwargs["timeout"] = timeout
191
+
192
+ if "headers" not in kwargs:
193
+ kwargs["headers"] = {}
194
+
195
+ headers = dict(self._session.headers)
196
+ headers.update(kwargs["headers"])
197
+ kwargs["headers"] = headers
198
+
199
+ kwargs.setdefault("verify", self.config.verify_ssl)
200
+
201
+ self._logger.debug(f"Making {method} request to {url}")
202
+ return self._session.request(method, url, **kwargs)
203
+
204
+ def post_with_retry(
205
+ self,
206
+ url: str,
207
+ data: dict[str, Any] | str,
208
+ headers: dict[str, str] | None = None,
209
+ timeout_key: str = "api",
210
+ max_retries: int | None = None,
211
+ operation_name: str = "",
212
+ ) -> requests.Response:
213
+ """Make POST request with retry logic."""
214
+ if max_retries is None:
215
+ max_retries = self.config.max_retries_default
216
+
217
+ def _attempt():
218
+ if isinstance(data, dict):
219
+ json_data = json.dumps(data, ensure_ascii=False)
220
+ else:
221
+ json_data = str(data)
222
+
223
+ response = self.make_request(
224
+ "POST",
225
+ url=url,
226
+ data=json_data,
227
+ headers=headers or {},
228
+ timeout_key=timeout_key,
229
+ )
230
+ self._handle_api_error(response, operation_name)
231
+ return response
232
+
233
+ return self._retry_on_failure(
234
+ _attempt,
235
+ max_retries=max_retries,
236
+ operation_name=operation_name,
237
+ )
238
+
239
+ def get_with_retry(
240
+ self,
241
+ url: str,
242
+ headers: dict[str, str] | None = None,
243
+ timeout_key: str = "status",
244
+ max_retries: int | None = None,
245
+ operation_name: str = "",
246
+ ) -> requests.Response:
247
+ """Make GET request with retry logic."""
248
+ if max_retries is None:
249
+ max_retries = self.config.max_retries_default
250
+
251
+ def _attempt():
252
+ response = self.make_request(
253
+ "GET",
254
+ url=url,
255
+ headers=headers or {},
256
+ timeout_key=timeout_key,
257
+ )
258
+ self._handle_api_error(response, operation_name)
259
+ return response
260
+
261
+ return self._retry_on_failure(
262
+ _attempt,
263
+ max_retries=max_retries,
264
+ operation_name=operation_name,
265
+ )
266
+
267
+ def put_with_retry(
268
+ self,
269
+ url: str,
270
+ data: bytes,
271
+ headers: dict[str, str] | None = None,
272
+ timeout_key: str = "upload",
273
+ max_retries: int | None = None,
274
+ operation_name: str = "",
275
+ ) -> requests.Response:
276
+ """Make PUT request with retry logic."""
277
+ if max_retries is None:
278
+ max_retries = self.config.max_retries_upload
279
+
280
+ def _attempt():
281
+ response = self.make_request(
282
+ "PUT",
283
+ url=url,
284
+ data=data,
285
+ headers=headers or {},
286
+ timeout_key=timeout_key,
287
+ )
288
+ self._handle_api_error(response, operation_name)
289
+ return response
290
+
291
+ return self._retry_on_failure(
292
+ _attempt,
293
+ max_retries=max_retries,
294
+ operation_name=operation_name,
295
+ )
@@ -0,0 +1,56 @@
1
+ import logging
2
+ import os
3
+ from logging.handlers import RotatingFileHandler
4
+
5
+
6
+ def init_logger(
7
+ logger_name: str | None = None,
8
+ log_dir: str = "logs",
9
+ log_file: str = "pdf_parser.log",
10
+ log_level: int | None = None,
11
+ max_bytes: int = 10 * 1024 * 1024,
12
+ backup_count: int = 5,
13
+ ):
14
+ """初始化日志配置:同时输出到控制台和文件
15
+
16
+ Args:
17
+ logger_name: logger 名称,默认为 "hn_deepocr_client"
18
+ log_dir: 日志目录,默认为 "logs"
19
+ log_file: 日志文件名,默认为 "pdf_parser.log"
20
+ log_level: 日志级别,默认为 logging.INFO,支持环境变量 LOG_LEVEL
21
+ max_bytes: 单个日志文件最大字节数,默认为 10MB
22
+ backup_count: 保留的备份文件数量,默认为 5
23
+
24
+ Returns:
25
+ 配置好的 logger 实例
26
+ """
27
+ if logger_name is None:
28
+ logger_name = "hn_deepocr_client"
29
+
30
+ effective_level = log_level
31
+ if effective_level is None:
32
+ log_level_str = os.environ.get("LOG_LEVEL", "INFO")
33
+ effective_level = getattr(logging, log_level_str.upper(), logging.INFO)
34
+
35
+ logger = logging.getLogger(logger_name)
36
+ if logger.handlers:
37
+ return logger
38
+
39
+ logger.setLevel(effective_level)
40
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
41
+
42
+ console_handler = logging.StreamHandler()
43
+ console_handler.setFormatter(formatter)
44
+ logger.addHandler(console_handler)
45
+
46
+ os.makedirs(log_dir, exist_ok=True)
47
+ file_handler = RotatingFileHandler(
48
+ filename=os.path.join(log_dir, log_file),
49
+ maxBytes=max_bytes,
50
+ backupCount=backup_count,
51
+ encoding="utf-8",
52
+ )
53
+ file_handler.setFormatter(formatter)
54
+ logger.addHandler(file_handler)
55
+
56
+ return logger