XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/gpt4free

refactor: restructure core utilities, typing, and request handling

- In `g4f/__init__.py`, changed logger setup to use fixed "g4f" name and refactored `ChatCompletion.create` and `create_async` to share `_prepare_request` logic for preprocessing arguments - In `g4f/config.py`, added `__future__.annotations`, `lru_cache` import, wrapped `get_config_dir` with `@lru_cache`, and simplified platform branch logic - In `g4f/cookies.py`, added typing imports, renamed `browsers` to `BROWSERS`, reformatted `DOMAINS`, updated docstrings, improved loop logic in `load_cookies_from_browsers` with additional exception handling, split HAR/JSON parsing into `_parse_har_file` and `_parse_json_cookie_file`, and enhanced `read_cookie_files` with optional filters and `.env` loading - In `g4f/debug.py`, added enable/disable logging functions, updated log handler typing, appended messages to `logs` in `log()`, and improved `error()` formatting - In `g4f/errors.py`, introduced base `G4FError` and updated all exception classes to inherit from it or relevant subclasses, with descriptive docstrings for each - In `g4f/files.py`, added `max_length` parameter to `secure_filename`, adjusted regex formatting, and added docstring; updated `get_bucket_dir` to sanitize parts inline with docstring - In `g4f/typing.py`, added `__future__.annotations`, reorganized imports, restricted PIL import to type-checking, defined `ContentPart` and `Message` TypedDicts, updated type aliases and `__all__` to include new types - In `g4f/version.py`, added `lru_cache` and request timeout constant, applied caching to `get_pypi_version` and `get_github_version`, added response validation and explicit exceptions, refactored `VersionUtils.current_version` with clearer sources and error on miss, changed `check_version` to return a boolean with optional silent mode, and improved error handling outputs

b6f51f00
hlohaus <983577+hlohaus@users.noreply.github.com>
提交于

代码差异

8 个文件 +442 -283
Modified g4f/__init__.py +46 -39
@@ -13,35 +13,38 @@ from .providers.types import ProviderType
13 13 from .providers.helper import concat_chunks, async_concat_chunks
14 14 from .client.service import get_model_and_provider
15 15
16 #Configure "g4f" logger
17 logger = logging.getLogger(__name__)
18 log_handler = logging.StreamHandler()
19 log_handler.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
20 logger.addHandler(log_handler)
21
16 # Configure logger
17 logger = logging.getLogger("g4f")
18 handler = logging.StreamHandler()
19 handler.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
20 logger.addHandler(handler)
22 21 logger.setLevel(logging.ERROR)
23 22
23
24 24 class ChatCompletion:
25 25 @staticmethod
26 def create(model : Union[Model, str],
27 messages : Messages,
28 provider : Union[ProviderType, str, None] = None,
29 stream : bool = False,
30 image : ImageType = None,
31 image_name: Optional[str] = None,
32 ignore_working: bool = False,
33 ignore_stream: bool = False,
34 **kwargs) -> Union[CreateResult, str]:
26 def _prepare_request(model: Union[Model, str],
27 messages: Messages,
28 provider: Union[ProviderType, str, None],
29 stream: bool,
30 image: ImageType,
31 image_name: Optional[str],
32 ignore_working: bool,
33 ignore_stream: bool,
34 **kwargs):
35 """Shared pre-processing for sync/async create methods."""
35 36 if image is not None:
36 37 kwargs["media"] = [(image, image_name)]
37 38 elif "images" in kwargs:
38 39 kwargs["media"] = kwargs.pop("images")
40
39 41 model, provider = get_model_and_provider(
40 42 model, provider, stream,
41 43 ignore_working,
42 44 ignore_stream,
43 45 has_images="media" in kwargs,
44 46 )
47
45 48 if "proxy" not in kwargs:
46 49 proxy = os.environ.get("G4F_PROXY")
47 50 if proxy:
@@ -49,36 +52,40 @@ class ChatCompletion:
49 52 if ignore_stream:
50 53 kwargs["ignore_stream"] = True
51 54
52 result = provider.create_function(model, messages, stream=stream, **kwargs)
55 return model, provider, kwargs
53 56
57 @staticmethod
58 def create(model: Union[Model, str],
59 messages: Messages,
60 provider: Union[ProviderType, str, None] = None,
61 stream: bool = False,
62 image: ImageType = None,
63 image_name: Optional[str] = None,
64 ignore_working: bool = False,
65 ignore_stream: bool = False,
66 **kwargs) -> Union[CreateResult, str]:
67 model, provider, kwargs = ChatCompletion._prepare_request(
68 model, messages, provider, stream, image, image_name,
69 ignore_working, ignore_stream, **kwargs
70 )
71 result = provider.create_function(model, messages, stream=stream, **kwargs)
54 72 return result if stream or ignore_stream else concat_chunks(result)
55 73
56 74 @staticmethod
57 def create_async(model : Union[Model, str],
58 messages : Messages,
59 provider : Union[ProviderType, str, None] = None,
60 stream : bool = False,
61 image : ImageType = None,
75 def create_async(model: Union[Model, str],
76 messages: Messages,
77 provider: Union[ProviderType, str, None] = None,
78 stream: bool = False,
79 image: ImageType = None,
62 80 image_name: Optional[str] = None,
63 ignore_stream: bool = False,
64 81 ignore_working: bool = False,
82 ignore_stream: bool = False,
65 83 **kwargs) -> Union[AsyncResult, Coroutine[str]]:
66 if image is not None:
67 kwargs["media"] = [(image, image_name)]
68 elif "images" in kwargs:
69 kwargs["media"] = kwargs.pop("images")
70 model, provider = get_model_and_provider(model, provider, False, ignore_working, has_images="media" in kwargs)
71 if "proxy" not in kwargs:
72 proxy = os.environ.get("G4F_PROXY")
73 if proxy:
74 kwargs["proxy"] = proxy
75 if ignore_stream:
76 kwargs["ignore_stream"] = True
77
84 model, provider, kwargs = ChatCompletion._prepare_request(
85 model, messages, provider, stream, image, image_name,
86 ignore_working, ignore_stream, **kwargs
87 )
78 88 result = provider.async_create_function(model, messages, stream=stream, **kwargs)
79
80 if not stream and not ignore_stream:
81 if hasattr(result, "__aiter__"):
82 result = async_concat_chunks(result)
83
89 if not stream and not ignore_stream and hasattr(result, "__aiter__"):
90 result = async_concat_chunks(result)
84 91 return result
Modified g4f/config.py +6 -3
@@ -1,16 +1,19 @@
1 from __future__ import annotations
2
1 3 import os
2 4 import sys
3 5 from pathlib import Path
6 from functools import lru_cache
4 7
5 # Platform-appropriate directories
8 @lru_cache(maxsize=1)
6 9 def get_config_dir() -> Path:
7 10 """Get platform-appropriate config directory."""
8 11 if sys.platform == "win32":
9 12 return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
10 13 elif sys.platform == "darwin":
11 14 return Path.home() / "Library" / "Application Support"
12 else: # Linux and other UNIX-like
13 return Path.home() / ".config"
15 return Path.home() / ".config"
16
14 17
15 18 PACKAGE_NAME = "g4f"
16 19 CONFIG_DIR = get_config_dir() / PACKAGE_NAME
Modified g4f/cookies.py +111 -106
@@ -3,12 +3,14 @@ from __future__ import annotations
3 3 import os
4 4 import time
5 5 import json
6 from typing import Optional, List
6 7
7 8 try:
8 9 from platformdirs import user_config_dir
9 10 has_platformdirs = True
10 11 except ImportError:
11 12 has_platformdirs = False
13
12 14 try:
13 15 from browser_cookie3 import (
14 16 chrome, chromium, opera, opera_gx,
@@ -19,12 +21,6 @@ try:
19 21 def g4f(domain_name: str) -> list:
20 22 """
21 23 Load cookies from the 'g4f' browser (if exists).
22
23 Args:
24 domain_name (str): The domain for which to load cookies.
25
26 Returns:
27 list: List of cookies.
28 24 """
29 25 if not has_platformdirs:
30 26 return []
@@ -32,7 +28,7 @@ try:
32 28 cookie_file = os.path.join(user_data_dir, "Default", "Cookies")
33 29 return [] if not os.path.exists(cookie_file) else chrome(cookie_file, domain_name)
34 30
35 browsers = [
31 BROWSERS = [
36 32 g4f, firefox,
37 33 chrome, chromium, opera, opera_gx,
38 34 brave, edge, vivaldi,
@@ -40,43 +36,38 @@ try:
40 36 has_browser_cookie3 = True
41 37 except ImportError:
42 38 has_browser_cookie3 = False
43 browsers = []
39 BROWSERS: List = []
44 40
45 41 from .typing import Dict, Cookies
46 42 from .errors import MissingRequirementsError
47 43 from .config import COOKIES_DIR, CUSTOM_COOKIES_DIR
48 44 from . import debug
49 45
50 class CookiesConfig():
46 class CookiesConfig:
51 47 cookies: Dict[str, Cookies] = {}
52 48 cookies_dir: str = CUSTOM_COOKIES_DIR if os.path.exists(CUSTOM_COOKIES_DIR) else str(COOKIES_DIR)
53 49
54 DOMAINS = [
50
51 DOMAINS = (
55 52 ".bing.com",
56 53 ".meta.ai",
57 54 ".google.com",
58 55 "www.whiterabbitneo.com",
59 56 "huggingface.co",
60 ".huggingface.co"
57 ".huggingface.co",
61 58 "chat.reka.ai",
62 59 "chatgpt.com",
63 60 ".cerebras.ai",
64 61 "github.com",
65 ]
62 )
66 63
67 if has_browser_cookie3 and os.environ.get('DBUS_SESSION_BUS_ADDRESS') == "/dev/null":
64 if has_browser_cookie3 and os.environ.get("DBUS_SESSION_BUS_ADDRESS") == "/dev/null":
68 65 _LinuxPasswordManager.get_password = lambda a, b: b"secret"
69 66
70 def get_cookies(domain_name: str, raise_requirements_error: bool = True, single_browser: bool = False, cache_result: bool = True) -> Dict[str, str]:
71 """
72 Load cookies for a given domain from all supported browsers and cache the results.
73
74 Args:
75 domain_name (str): The domain for which to load cookies.
76 67
77 Returns:
78 Dict[str, str]: A dictionary of cookie names and values.
79 """
68 def get_cookies(domain_name: str, raise_requirements_error: bool = True,
69 single_browser: bool = False, cache_result: bool = True) -> Dict[str, str]:
70 """Load cookies for a given domain from all supported browsers."""
80 71 if domain_name in CookiesConfig.cookies:
81 72 return CookiesConfig.cookies[domain_name]
82 73
@@ -85,120 +76,134 @@ def get_cookies(domain_name: str, raise_requirements_error: bool = True, single_
85 76 CookiesConfig.cookies[domain_name] = cookies
86 77 return cookies
87 78
79
88 80 def set_cookies(domain_name: str, cookies: Cookies = None) -> None:
81 """Set or remove cookies for a given domain in the cache."""
89 82 if cookies:
90 83 CookiesConfig.cookies[domain_name] = cookies
91 elif domain_name in CookiesConfig.cookies:
92 CookiesConfig.cookies.pop(domain_name)
93
94 def load_cookies_from_browsers(domain_name: str, raise_requirements_error: bool = True, single_browser: bool = False) -> Cookies:
95 """
96 Helper function to load cookies from various browsers.
84 else:
85 CookiesConfig.cookies.pop(domain_name, None)
97 86
98 Args:
99 domain_name (str): The domain for which to load cookies.
100 87
101 Returns:
102 Dict[str, str]: A dictionary of cookie names and values.
103 """
88 def load_cookies_from_browsers(domain_name: str,
89 raise_requirements_error: bool = True,
90 single_browser: bool = False) -> Cookies:
91 """Helper to load cookies from all supported browsers."""
104 92 if not has_browser_cookie3:
105 93 if raise_requirements_error:
106 94 raise MissingRequirementsError('Install "browser_cookie3" package')
107 95 return {}
96
108 97 cookies = {}
109 for cookie_fn in browsers:
98 for cookie_fn in BROWSERS:
110 99 try:
111 100 cookie_jar = cookie_fn(domain_name=domain_name)
112 if len(cookie_jar):
101 if cookie_jar:
113 102 debug.log(f"Read cookies from {cookie_fn.__name__} for {domain_name}")
114 103 for cookie in cookie_jar:
115 if cookie.name not in cookies:
116 if not cookie.expires or cookie.expires > time.time():
117 cookies[cookie.name] = cookie.value
118 if single_browser and len(cookie_jar):
104 if cookie.name not in cookies and (not cookie.expires or cookie.expires > time.time()):
105 cookies[cookie.name] = cookie.value
106 if single_browser and cookie_jar:
119 107 break
120 108 except BrowserCookieError:
121 109 pass
110 except KeyboardInterrupt:
111 debug.error("Cookie loading interrupted by user.")
112 break
122 113 except Exception as e:
123 114 debug.error(f"Error reading cookies from {cookie_fn.__name__} for {domain_name}: {e}")
124 115 return cookies
125 116
126 def set_cookies_dir(dir: str) -> None:
127 CookiesConfig.cookies_dir = dir
117
118 def set_cookies_dir(dir_path: str) -> None:
119 CookiesConfig.cookies_dir = dir_path
120
128 121
129 122 def get_cookies_dir() -> str:
130 123 return CookiesConfig.cookies_dir
131 124
132 def read_cookie_files(dirPath: str = None):
133 dirPath = CookiesConfig.cookies_dir if dirPath is None else dirPath
134 if not os.access(dirPath, os.R_OK):
135 debug.log(f"Read cookies: {dirPath} dir is not readable")
125
126 def _parse_har_file(path: str) -> Dict[str, Dict[str, str]]:
127 """Parse a HAR file and return cookies by domain."""
128 cookies_by_domain = {}
129 try:
130 with open(path, "rb") as file:
131 har_file = json.load(file)
132 debug.log(f"Read .har file: {path}")
133
134 def get_domain(entry: dict) -> Optional[str]:
135 headers = entry["request"].get("headers", [])
136 host_values = [h["value"] for h in headers if h["name"].lower() in ("host", ":authority")]
137 if not host_values:
138 return None
139 host = host_values.pop()
140 return next((d for d in DOMAINS if d in host), None)
141
142 for entry in har_file.get("log", {}).get("entries", []):
143 domain = get_domain(entry)
144 if domain:
145 v_cookies = {c["name"]: c["value"] for c in entry["request"].get("cookies", [])}
146 if v_cookies:
147 cookies_by_domain[domain] = v_cookies
148 except (json.JSONDecodeError, FileNotFoundError):
149 pass
150 return cookies_by_domain
151
152
153 def _parse_json_cookie_file(path: str) -> Dict[str, Dict[str, str]]:
154 """Parse a JSON cookie export file."""
155 cookies_by_domain = {}
156 try:
157 with open(path, "rb") as file:
158 cookie_file = json.load(file)
159 if not isinstance(cookie_file, list):
160 return {}
161 debug.log(f"Read cookie file: {path}")
162 for c in cookie_file:
163 if isinstance(c, dict) and "domain" in c:
164 cookies_by_domain.setdefault(c["domain"], {})[c["name"]] = c["value"]
165 except (json.JSONDecodeError, FileNotFoundError):
166 pass
167 return cookies_by_domain
168
169
170 def read_cookie_files(dir_path: Optional[str] = None, domains_filter: Optional[List[str]] = None) -> None:
171 """
172 Load cookies from .har and .json files in a directory.
173 """
174 dir_path = dir_path or CookiesConfig.cookies_dir
175 if not os.access(dir_path, os.R_OK):
176 debug.log(f"Read cookies: {dir_path} dir is not readable")
136 177 return
137 178
179 # Optionally load environment variables
138 180 try:
139 181 from dotenv import load_dotenv
140 load_dotenv(os.path.join(dirPath, ".env"), override=True)
141 debug.log(f"Read cookies: Loaded environment variables from {dirPath}/.env")
182 load_dotenv(os.path.join(dir_path, ".env"), override=True)
183 debug.log(f"Read cookies: Loaded env vars from {dir_path}/.env")
142 184 except ImportError:
143 debug.error("Warning: 'python-dotenv' is not installed. Environment variables will not be loaded.")
144
145 def get_domain(v: dict) -> str:
146 host = [h["value"] for h in v['request']['headers'] if h["name"].lower() in ("host", ":authority")]
147 if not host:
148 return
149 host = host.pop()
150 for d in DOMAINS:
151 if d in host:
152 return d
153
154 harFiles = []
155 cookieFiles = []
156 for root, _, files in os.walk(dirPath):
185 debug.error("Warning: 'python-dotenv' is not installed. Env vars not loaded.")
186
187 har_files, json_files = [], []
188 for root, _, files in os.walk(dir_path):
157 189 for file in files:
158 190 if file.endswith(".har"):
159 harFiles.append(os.path.join(root, file))
191 har_files.append(os.path.join(root, file))
160 192 elif file.endswith(".json"):
161 cookieFiles.append(os.path.join(root, file))
162 break
163
164 CookiesConfig.cookies = {}
165 for path in harFiles:
166 with open(path, 'rb') as file:
167 try:
168 harFile = json.load(file)
169 except json.JSONDecodeError:
170 # Error: not a HAR file!
171 continue
172 debug.log(f"Read .har file: {path}")
173 new_cookies = {}
174 for v in harFile['log']['entries']:
175 domain = get_domain(v)
176 if domain is None:
177 continue
178 v_cookies = {}
179 for c in v['request']['cookies']:
180 v_cookies[c['name']] = c['value']
181 if len(v_cookies) > 0:
182 CookiesConfig.cookies[domain] = v_cookies
183 new_cookies[domain] = len(v_cookies)
184 for domain, new_values in new_cookies.items():
185 debug.log(f"Cookies added: {new_values} from {domain}")
186 for path in cookieFiles:
187 with open(path, 'rb') as file:
188 try:
189 cookieFile = json.load(file)
190 except json.JSONDecodeError:
191 # Error: not a json file!
192 continue
193 if not isinstance(cookieFile, list) or not isinstance(cookieFile[0], dict) or "domain" not in cookieFile[0]:
194 continue
195 debug.log(f"Read cookie file: {path}")
196 new_cookies = {}
197 for c in cookieFile:
198 if isinstance(c, dict) and "domain" in c:
199 if c["domain"] not in new_cookies:
200 new_cookies[c["domain"]] = {}
201 new_cookies[c["domain"]][c["name"]] = c["value"]
202 for domain, new_values in new_cookies.items():
203 CookiesConfig.cookies[domain] = new_values
204 debug.log(f"Cookies added: {len(new_values)} from {domain}")
193 json_files.append(os.path.join(root, file))
194 break # Do not recurse
195
196 CookiesConfig.cookies.clear()
197
198 # Load cookies from files
199 for path in har_files:
200 for domain, cookies in _parse_har_file(path).items():
201 if not domains_filter or domain in domains_filter:
202 CookiesConfig.cookies[domain] = cookies
203 debug.log(f"Cookies added: {len(cookies)} from {domain}")
204
205 for path in json_files:
206 for domain, cookies in _parse_json_cookie_file(path).items():
207 if not domains_filter or domain in domains_filter:
208 CookiesConfig.cookies[domain] = cookies
209 debug.log(f"Cookies added: {len(cookies)} from {domain}")
Modified g4f/debug.py +24 -4
@@ -4,15 +4,35 @@ from typing import Callable, List, Optional, Any
4 4 logging: bool = False
5 5 version_check: bool = True
6 6 version: Optional[str] = None
7 log_handler: Callable = print # More specifically: Callable[[Any, Optional[Any]], None]
7 log_handler: Callable[..., None] = print
8 8 logs: List[str] = []
9 9
10
11 def enable_logging(handler: Callable[..., None] = print) -> None:
12 """Enable debug logging with optional handler."""
13 global logging, log_handler
14 logging = True
15 log_handler = handler
16
17
18 def disable_logging() -> None:
19 """Disable debug logging."""
20 global logging
21 logging = False
22
23
10 24 def log(*text: Any, file: Optional[Any] = None) -> None:
11 25 """Log a message if logging is enabled."""
12 26 if logging:
27 message = " ".join(map(str, text))
28 logs.append(message)
13 29 log_handler(*text, file=file)
14 30
15 def error(*error: Any, name: Optional[str] = None) -> None:
31
32 def error(*error_args: Any, name: Optional[str] = None) -> None:
16 33 """Log an error message to stderr."""
17 error = [e if isinstance(e, str) else f"{type(e).__name__ if name is None else name}: {e}" for e in error]
18 log(*error, file=sys.stderr)
34 formatted_errors = [
35 e if isinstance(e, str) else f"{name or type(e).__name__}: {e}"
36 for e in error_args
37 ]
38 log(*formatted_errors, file=sys.stderr)
Modified g4f/errors.py +80 -36
@@ -1,59 +1,103 @@
1 class ProviderNotFoundError(Exception):
2 ...
1 class G4FError(Exception):
2 """Base exception for all g4f-related errors."""
3 pass
3 4
4 class ProviderNotWorkingError(Exception):
5 ...
6 5
7 class StreamNotSupportedError(Exception):
8 ...
6 class ProviderNotFoundError(G4FError):
7 """Raised when a provider is not found."""
8 pass
9 9
10 class ModelNotFoundError(Exception):
11 ...
12 10
13 class ModelNotAllowedError(Exception):
14 ...
11 class ProviderNotWorkingError(G4FError):
12 """Raised when the provider is unavailable or failing."""
13 pass
15 14
16 class RetryProviderError(Exception):
17 ...
18 15
19 class RetryNoProviderError(Exception):
20 ...
16 class StreamNotSupportedError(G4FError):
17 """Raised when the requested provider does not support streaming."""
18 pass
21 19
22 class VersionNotFoundError(Exception):
23 ...
24 20
25 class MissingRequirementsError(Exception):
26 ...
21 class ModelNotFoundError(G4FError):
22 """Raised when a model is not found."""
23 pass
24
25
26 class ModelNotAllowedError(G4FError):
27 """Raised when a model is not allowed by configuration or policy."""
28 pass
29
30
31 class RetryProviderError(G4FError):
32 """Raised to retry with another provider."""
33 pass
34
35
36 class RetryNoProviderError(G4FError):
37 """Raised when there are no providers left to retry."""
38 pass
39
40
41 class VersionNotFoundError(G4FError):
42 """Raised when the version could not be determined."""
43 pass
44
45
46 class MissingRequirementsError(G4FError):
47 """Raised when a required dependency is missing."""
48 pass
49
27 50
28 51 class NestAsyncioError(MissingRequirementsError):
29 ...
52 """Raised when 'nest_asyncio' is missing."""
53 pass
30 54
31 class MissingAuthError(Exception):
32 ...
33 55
34 class PaymentRequiredError(Exception):
35 ...
56 class MissingAuthError(G4FError):
57 """Raised when authentication details are missing."""
58 pass
36 59
37 class NoMediaResponseError(Exception):
38 ...
39 60
40 class ResponseError(Exception):
41 ...
61 class PaymentRequiredError(G4FError):
62 """Raised when a provider requires payment before access."""
63 pass
64
65
66 class NoMediaResponseError(G4FError):
67 """Raised when a media request returns no response."""
68 pass
69
70
71 class ResponseError(G4FError):
72 """Base class for response-related errors."""
73 pass
74
75
76 class ResponseStatusError(ResponseError):
77 """Raised when an HTTP response returns a non-success status code."""
78 pass
42 79
43 class ResponseStatusError(Exception):
44 ...
45 80
46 81 class CloudflareError(ResponseStatusError):
47 ...
82 """Raised when a request is blocked by Cloudflare."""
83 pass
84
48 85
49 86 class RateLimitError(ResponseStatusError):
50 ...
87 """Raised when the provider's rate limit has been exceeded."""
88 pass
89
51 90
52 class NoValidHarFileError(Exception):
53 ...
91 class NoValidHarFileError(G4FError):
92 """Raised when no valid HAR file is found."""
93 pass
54 94
55 class TimeoutError(Exception):
95
96 class TimeoutError(G4FError):
56 97 """Raised for timeout errors during API requests."""
98 pass
99
57 100
58 class ConversationLimitError(Exception):
59 """Raised for conversation limit during API requests to AI endpoint."""
101 class ConversationLimitError(G4FError):
102 """Raised when a conversation limit is reached on the provider."""
103 pass
Modified g4f/files.py +18 -10
@@ -1,26 +1,34 @@
1 1 from __future__ import annotations
2 2
3 3 import re
4 from urllib.parse import unquote
5 4 import os
5 from urllib.parse import unquote
6 6
7 7 from .cookies import get_cookies_dir
8 8
9 def secure_filename(filename: str) -> str:
9
10 def secure_filename(filename: str, max_length: int = 100) -> str:
11 """Sanitize a filename for safe filesystem storage."""
10 12 if filename is None:
11 13 return None
12 # Keep letters, numbers, basic punctuation and all Unicode chars
14
15 # Keep letters, numbers, basic punctuation, underscores
13 16 filename = re.sub(
14 r'[^\w.,_+-]+',
15 '_',
17 r"[^\w.,_+\-]+",
18 "_",
16 19 unquote(filename).strip(),
17 20 flags=re.UNICODE
18 21 )
19 encoding = 'utf-8'
20 max_length = 100
22 encoding = "utf-8"
21 23 encoded = filename.encode(encoding)[:max_length]
22 decoded = encoded.decode(encoding, 'ignore')
24 decoded = encoded.decode(encoding, "ignore")
23 25 return decoded.strip(".,_+-")
24 26
25 def get_bucket_dir(*parts):
26 return os.path.join(get_cookies_dir(), "buckets", *[secure_filename(part) for part in parts if part])
27
28 def get_bucket_dir(*parts: str) -> str:
29 """Return a path under the cookies 'buckets' directory with sanitized parts."""
30 return os.path.join(
31 get_cookies_dir(),
32 "buckets",
33 *[secure_filename(part) for part in parts if part]
34 )
Modified g4f/typing.py +78 -31
@@ -1,42 +1,89 @@
1 from __future__ import annotations
2
1 3 import os
2 from typing import Any, AsyncGenerator, Generator, AsyncIterator, Iterator, NewType, Tuple, Union, List, Dict, Type, IO, Optional, TypedDict
4 from typing import (
5 Any,
6 AsyncGenerator,
7 Generator,
8 AsyncIterator,
9 Iterator,
10 NewType,
11 Tuple,
12 Union,
13 List,
14 Dict,
15 Type,
16 IO,
17 Optional,
18 TypedDict,
19 TYPE_CHECKING,
20 )
3 21
4 try:
5 from PIL.Image import Image
6 except ImportError:
7 class Image:
22 # Only import PIL for type-checkers; no runtime dependency required.
23 if TYPE_CHECKING:
24 from PIL.Image import Image as PILImage
25 else:
26 class PILImage: # minimal placeholder to avoid runtime import errors
8 27 pass
9 28
29 # Response chunk type from providers
10 30 from .providers.response import ResponseType
11 31
12 SHA256 = NewType('sha_256_hash', str)
32 # ---- Hashes & cookie aliases -------------------------------------------------
33
34 SHA256 = NewType("SHA256", str)
35 Cookies = Dict[str, str]
36
37 # ---- Streaming result types --------------------------------------------------
38
13 39 CreateResult = Iterator[Union[str, ResponseType]]
14 40 AsyncResult = AsyncIterator[Union[str, ResponseType]]
15 Messages = List[Dict[str, Union[str, List[Dict[str, Union[str, Dict[str, str]]]]]]]
16 Cookies = Dict[str, str]
17 ImageType = Union[str, bytes, IO, Image, os.PathLike]
41
42 # ---- Message schema ----------------------------------------------------------
43 # Typical message structure:
44 # {"role": "user" | "assistant" | "system" | "tool", "content": str | [ContentPart, ...]}
45 # where content parts can be text or (optionally) structured pieces like images.
46
47 class ContentPart(TypedDict, total=False):
48 type: str # e.g., "text", "image_url", etc.
49 text: str # present when type == "text"
50 image_url: Dict[str, str] # present when type == "image_url"
51
52 class Message(TypedDict):
53 role: str
54 content: Union[str, List[ContentPart]]
55
56 Messages = List[Message]
57
58 # ---- Media inputs ------------------------------------------------------------
59
60 # Paths, raw bytes, file-like objects, or PIL Image objects are accepted.
61 ImageType = Union[str, bytes, IO[bytes], PILImage, os.PathLike]
18 62 MediaListType = List[Tuple[ImageType, Optional[str]]]
19 63
20 64 __all__ = [
21 'Any',
22 'AsyncGenerator',
23 'Generator',
24 'AsyncIterator',
25 'Iterator'
26 'Tuple',
27 'Union',
28 'List',
29 'Dict',
30 'Type',
31 'IO',
32 'Optional',
33 'TypedDict',
34 'SHA256',
35 'CreateResult',
36 'AsyncResult',
37 'Messages',
38 'Cookies',
39 'Image',
40 'ImageType',
41 'MediaListType'
42 ]
65 "Any",
66 "AsyncGenerator",
67 "Generator",
68 "AsyncIterator",
69 "Iterator",
70 "Tuple",
71 "Union",
72 "List",
73 "Dict",
74 "Type",
75 "IO",
76 "Optional",
77 "TypedDict",
78 "SHA256",
79 "CreateResult",
80 "AsyncResult",
81 "Messages",
82 "Message",
83 "ContentPart",
84 "Cookies",
85 "Image",
86 "ImageType",
87 "MediaListType",
88 "ResponseType",
89 ]
Modified g4f/version.py +79 -54
@@ -1,102 +1,114 @@
1 1 from __future__ import annotations
2 2
3 from os import environ
4 3 import requests
5 from functools import cached_property
4 from os import environ
5 from functools import cached_property, lru_cache
6 6 from importlib.metadata import version as get_package_version, PackageNotFoundError
7 7 from subprocess import check_output, CalledProcessError, PIPE
8
8 9 from .errors import VersionNotFoundError
9 10 from .config import PACKAGE_NAME, GITHUB_REPOSITORY
10 11 from . import debug
11 12
13 # Default request timeout (seconds)
14 REQUEST_TIMEOUT = 5
15
16
17 @lru_cache(maxsize=1)
12 18 def get_pypi_version(package_name: str) -> str:
13 19 """
14 20 Retrieves the latest version of a package from PyPI.
15 21
16 Args:
17 package_name (str): The name of the package for which to retrieve the version.
18
19 Returns:
20 str: The latest version of the specified package from PyPI.
21
22 22 Raises:
23 VersionNotFoundError: If there is an error in fetching the version from PyPI.
23 VersionNotFoundError: If there is a network or parsing error.
24 24 """
25 25 try:
26 response = requests.get(f"https://pypi.org/pypi/{package_name}/json").json()
27 return response["info"]["version"]
26 response = requests.get(
27 f"https://pypi.org/pypi/{package_name}/json",
28 timeout=REQUEST_TIMEOUT
29 )
30 response.raise_for_status()
31 return response.json()["info"]["version"]
28 32 except requests.RequestException as e:
29 raise VersionNotFoundError(f"Failed to get PyPI version: {e}")
33 raise VersionNotFoundError(
34 f"Failed to get PyPI version for '{package_name}'"
35 ) from e
36
30 37
38 @lru_cache(maxsize=1)
31 39 def get_github_version(repo: str) -> str:
32 40 """
33 41 Retrieves the latest release version from a GitHub repository.
34 42
35 Args:
36 repo (str): The name of the GitHub repository.
37
38 Returns:
39 str: The latest release version from the specified GitHub repository.
40
41 43 Raises:
42 VersionNotFoundError: If there is an error in fetching the version from GitHub.
44 VersionNotFoundError: If there is a network or parsing error.
43 45 """
44 46 try:
45 response = requests.get(f"https://api.github.com/repos/{repo}/releases/latest")
47 response = requests.get(
48 f"https://api.github.com/repos/{repo}/releases/latest",
49 timeout=REQUEST_TIMEOUT
50 )
46 51 response.raise_for_status()
47 return response.json()["tag_name"]
52 data = response.json()
53 if "tag_name" not in data:
54 raise VersionNotFoundError(f"No tag_name found in latest GitHub release for '{repo}'")
55 return data["tag_name"]
48 56 except requests.RequestException as e:
49 raise VersionNotFoundError(f"Failed to get GitHub release version: {e}")
57 raise VersionNotFoundError(
58 f"Failed to get GitHub release version for '{repo}'"
59 ) from e
60
50 61
51 def get_git_version() -> str:
52 # Read from git repository
62 def get_git_version() -> str | None:
63 """Return latest Git tag if available, else None."""
53 64 try:
54 command = ["git", "describe", "--tags", "--abbrev=0"]
55 return check_output(command, text=True, stderr=PIPE).strip()
65 return check_output(
66 ["git", "describe", "--tags", "--abbrev=0"],
67 text=True,
68 stderr=PIPE
69 ).strip()
56 70 except CalledProcessError:
57 71 return None
58 72
73
59 74 class VersionUtils:
60 75 """
61 76 Utility class for managing and comparing package versions of 'g4f'.
62 77 """
78
63 79 @cached_property
64 80 def current_version(self) -> str:
65 81 """
66 Retrieves the current version of the 'g4f' package.
67
68 Returns:
69 str: The current version of 'g4f'.
70
71 Raises:
72 VersionNotFoundError: If the version cannot be determined from the package manager,
73 Docker environment, or git repository.
82 Returns the current installed version of g4f from:
83 - debug override
84 - package metadata
85 - environment variable (Docker)
86 - git tags
74 87 """
75 88 if debug.version:
76 89 return debug.version
77 90
78 # Read from package manager
79 91 try:
80 92 return get_package_version(PACKAGE_NAME)
81 93 except PackageNotFoundError:
82 94 pass
83 95
84 # Read from docker environment
85 version = environ.get("G4F_VERSION")
86 if version:
87 return version
96 version_env = environ.get("G4F_VERSION")
97 if version_env:
98 return version_env
99
100 git_version = get_git_version()
101 if git_version:
102 return git_version
88 103
89 return get_git_version()
104 raise VersionNotFoundError("Could not determine current g4f version.")
90 105
91 106 @property
92 107 def latest_version(self) -> str:
93 108 """
94 Retrieves the latest version of the 'g4f' package.
95
96 Returns:
97 str: The latest version of 'g4f'.
109 Returns the latest available version of g4f.
110 If not installed via PyPI, falls back to GitHub releases.
98 111 """
99 # Is installed via package manager?
100 112 try:
101 113 get_package_version(PACKAGE_NAME)
102 114 except PackageNotFoundError:
@@ -107,17 +119,30 @@ class VersionUtils:
107 119 def latest_version_cached(self) -> str:
108 120 return self.latest_version
109 121
110 def check_version(self) -> None:
122 def check_version(self, silent: bool = False) -> bool:
111 123 """
112 Checks if the current version of 'g4f' is up to date with the latest version.
113
114 Note:
115 If a newer version is available, it prints a message with the new version and update instructions.
124 Checks if the current version is up-to-date.
125 Returns:
126 bool: True if current version is the latest, False otherwise.
116 127 """
117 128 try:
118 if self.current_version != self.latest_version:
119 print(f'New g4f version: {self.latest_version} (current: {self.current_version}) | pip install -U g4f')
129 current = self.current_version
130 latest = self.latest_version
131 up_to_date = current == latest
132 if not silent:
133 if up_to_date:
134 print(f"g4f is up-to-date (version {current}).")
135 else:
136 print(
137 f"New g4f version available: {latest} "
138 f"(current: {current}) | pip install -U g4f"
139 )
140 return up_to_date
120 141 except Exception as e:
121 print(f'Failed to check g4f version: {e}')
142 if not silent:
143 print(f"Failed to check g4f version: {e}")
144 return True # Assume up-to-date if check fails
145
122 146
123 utils = VersionUtils()
147 # Singleton instance
148 utils = VersionUtils()