gac 1.13.0__py3-none-any.whl → 3.8.1__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.
- gac/__version__.py +1 -1
- gac/ai.py +33 -47
- gac/ai_utils.py +113 -41
- gac/auth_cli.py +214 -0
- gac/cli.py +72 -2
- gac/config.py +63 -6
- gac/config_cli.py +26 -5
- gac/constants.py +178 -2
- gac/git.py +158 -12
- gac/init_cli.py +40 -125
- gac/language_cli.py +378 -0
- gac/main.py +868 -158
- gac/model_cli.py +429 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +323 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +3 -3
- gac/prompt.py +573 -226
- gac/providers/__init__.py +49 -0
- gac/providers/anthropic.py +11 -1
- gac/providers/azure_openai.py +101 -0
- gac/providers/cerebras.py +11 -1
- gac/providers/chutes.py +11 -1
- gac/providers/claude_code.py +112 -0
- gac/providers/custom_anthropic.py +6 -2
- gac/providers/custom_openai.py +6 -3
- gac/providers/deepseek.py +11 -1
- gac/providers/fireworks.py +11 -1
- gac/providers/gemini.py +11 -1
- gac/providers/groq.py +5 -1
- gac/providers/kimi_coding.py +67 -0
- gac/providers/lmstudio.py +12 -1
- gac/providers/minimax.py +11 -1
- gac/providers/mistral.py +48 -0
- gac/providers/moonshot.py +48 -0
- gac/providers/ollama.py +11 -1
- gac/providers/openai.py +11 -1
- gac/providers/openrouter.py +11 -1
- gac/providers/qwen.py +76 -0
- gac/providers/replicate.py +110 -0
- gac/providers/streamlake.py +11 -1
- gac/providers/synthetic.py +11 -1
- gac/providers/together.py +11 -1
- gac/providers/zai.py +11 -1
- gac/security.py +1 -1
- gac/utils.py +272 -4
- gac/workflow_utils.py +217 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
- gac-3.8.1.dist-info/RECORD +56 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
- gac-1.13.0.dist-info/RECORD +0 -41
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +0 -0
gac/oauth/qwen_oauth.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Qwen OAuth device flow implementation.
|
|
2
|
+
|
|
3
|
+
Implements OAuth 2.0 Device Authorization Grant (RFC 8628) with PKCE.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from gac import __version__
|
|
18
|
+
from gac.errors import AIError
|
|
19
|
+
from gac.oauth.token_store import OAuthToken, TokenStore
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
|
|
24
|
+
USER_AGENT = f"gac/{__version__}"
|
|
25
|
+
QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
|
26
|
+
QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token"
|
|
27
|
+
QWEN_SCOPES = ["openid", "profile", "email", "model.completion"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DeviceCodeResponse:
|
|
32
|
+
"""Response from the device authorization endpoint."""
|
|
33
|
+
|
|
34
|
+
device_code: str
|
|
35
|
+
user_code: str
|
|
36
|
+
verification_uri: str
|
|
37
|
+
verification_uri_complete: str | None
|
|
38
|
+
expires_in: int
|
|
39
|
+
interval: int = 5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class QwenDeviceFlow:
|
|
44
|
+
"""Qwen OAuth device flow implementation with PKCE."""
|
|
45
|
+
|
|
46
|
+
client_id: str = QWEN_CLIENT_ID
|
|
47
|
+
authorization_endpoint: str = QWEN_DEVICE_CODE_ENDPOINT
|
|
48
|
+
token_endpoint: str = QWEN_TOKEN_ENDPOINT
|
|
49
|
+
scopes: list[str] = field(default_factory=lambda: QWEN_SCOPES.copy())
|
|
50
|
+
_pkce_verifier: str = field(default="", init=False)
|
|
51
|
+
|
|
52
|
+
def _generate_pkce(self) -> tuple[str, str]:
|
|
53
|
+
"""Generate PKCE code verifier and challenge.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Tuple of (verifier, challenge) strings.
|
|
57
|
+
"""
|
|
58
|
+
verifier = secrets.token_urlsafe(32)
|
|
59
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
60
|
+
return verifier, challenge
|
|
61
|
+
|
|
62
|
+
def initiate_device_flow(self) -> DeviceCodeResponse:
|
|
63
|
+
"""Initiate the device authorization flow.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
DeviceCodeResponse with device code and verification URIs.
|
|
67
|
+
"""
|
|
68
|
+
verifier, challenge = self._generate_pkce()
|
|
69
|
+
self._pkce_verifier = verifier
|
|
70
|
+
|
|
71
|
+
params = {
|
|
72
|
+
"client_id": self.client_id,
|
|
73
|
+
"code_challenge": challenge,
|
|
74
|
+
"code_challenge_method": "S256",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if self.scopes:
|
|
78
|
+
params["scope"] = " ".join(self.scopes)
|
|
79
|
+
|
|
80
|
+
response = httpx.post(
|
|
81
|
+
self.authorization_endpoint,
|
|
82
|
+
data=params,
|
|
83
|
+
headers={
|
|
84
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
85
|
+
"Accept": "application/json",
|
|
86
|
+
"User-Agent": USER_AGENT,
|
|
87
|
+
},
|
|
88
|
+
timeout=30,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not response.is_success:
|
|
92
|
+
raise AIError.connection_error(f"Failed to initiate device flow: HTTP {response.status_code}")
|
|
93
|
+
|
|
94
|
+
data = response.json()
|
|
95
|
+
return DeviceCodeResponse(
|
|
96
|
+
device_code=data["device_code"],
|
|
97
|
+
user_code=data["user_code"],
|
|
98
|
+
verification_uri=data["verification_uri"],
|
|
99
|
+
verification_uri_complete=data.get("verification_uri_complete"),
|
|
100
|
+
expires_in=data["expires_in"],
|
|
101
|
+
interval=data.get("interval", 5),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def poll_for_token(self, device_code: str, max_duration: int = 900) -> OAuthToken:
|
|
105
|
+
"""Poll the authorization server for an access token.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
device_code: Device code from initiation response.
|
|
109
|
+
max_duration: Maximum polling duration in seconds (default 15 minutes).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
OAuthToken with access token and metadata.
|
|
113
|
+
"""
|
|
114
|
+
start_time = time.time()
|
|
115
|
+
interval = 5
|
|
116
|
+
|
|
117
|
+
while time.time() - start_time < max_duration:
|
|
118
|
+
params = {
|
|
119
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
120
|
+
"device_code": device_code,
|
|
121
|
+
"client_id": self.client_id,
|
|
122
|
+
"code_verifier": self._pkce_verifier,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
response = httpx.post(
|
|
127
|
+
self.token_endpoint,
|
|
128
|
+
data=params,
|
|
129
|
+
headers={
|
|
130
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
131
|
+
"Accept": "application/json",
|
|
132
|
+
"User-Agent": USER_AGENT,
|
|
133
|
+
},
|
|
134
|
+
timeout=30,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if response.is_success:
|
|
138
|
+
data = response.json()
|
|
139
|
+
now = int(time.time())
|
|
140
|
+
expires_in = data.get("expires_in", 3600)
|
|
141
|
+
|
|
142
|
+
return OAuthToken(
|
|
143
|
+
access_token=data["access_token"],
|
|
144
|
+
token_type="Bearer",
|
|
145
|
+
expiry=now + expires_in,
|
|
146
|
+
refresh_token=data.get("refresh_token"),
|
|
147
|
+
scope=data.get("scope"),
|
|
148
|
+
resource_url=data.get("resource_url"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
error_data = response.json()
|
|
152
|
+
error = error_data.get("error", "")
|
|
153
|
+
|
|
154
|
+
if error == "authorization_pending":
|
|
155
|
+
time.sleep(interval)
|
|
156
|
+
continue
|
|
157
|
+
elif error == "slow_down":
|
|
158
|
+
interval += 5
|
|
159
|
+
time.sleep(interval)
|
|
160
|
+
continue
|
|
161
|
+
elif error == "access_denied":
|
|
162
|
+
raise AIError.authentication_error("Authorization was denied by user")
|
|
163
|
+
elif error == "expired_token":
|
|
164
|
+
raise AIError.authentication_error("Device code expired. Please try again.")
|
|
165
|
+
|
|
166
|
+
raise AIError.connection_error(f"Token request failed: {response.status_code}")
|
|
167
|
+
|
|
168
|
+
except httpx.RequestError as e:
|
|
169
|
+
interval = int(min(interval * 1.5, 60))
|
|
170
|
+
logger.debug(f"Network error during polling, retrying in {interval}s: {e}")
|
|
171
|
+
time.sleep(interval)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
raise AIError.timeout_error("Authorization timeout exceeded. Please try again.")
|
|
175
|
+
|
|
176
|
+
def refresh_token(self, refresh_token: str) -> OAuthToken:
|
|
177
|
+
"""Refresh an expired access token.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
refresh_token: Valid refresh token.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
New OAuthToken with refreshed access token.
|
|
184
|
+
"""
|
|
185
|
+
params = {
|
|
186
|
+
"grant_type": "refresh_token",
|
|
187
|
+
"refresh_token": refresh_token,
|
|
188
|
+
"client_id": self.client_id,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
response = httpx.post(
|
|
192
|
+
self.token_endpoint,
|
|
193
|
+
data=params,
|
|
194
|
+
headers={
|
|
195
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
196
|
+
"Accept": "application/json",
|
|
197
|
+
"User-Agent": USER_AGENT,
|
|
198
|
+
},
|
|
199
|
+
timeout=30,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not response.is_success:
|
|
203
|
+
raise AIError.authentication_error(f"Token refresh failed: HTTP {response.status_code}")
|
|
204
|
+
|
|
205
|
+
data = response.json()
|
|
206
|
+
now = int(time.time())
|
|
207
|
+
expires_in = data.get("expires_in", 3600)
|
|
208
|
+
|
|
209
|
+
return OAuthToken(
|
|
210
|
+
access_token=data["access_token"],
|
|
211
|
+
token_type="Bearer",
|
|
212
|
+
expiry=now + expires_in - 30,
|
|
213
|
+
refresh_token=data.get("refresh_token") or refresh_token,
|
|
214
|
+
scope=data.get("scope"),
|
|
215
|
+
resource_url=data.get("resource_url"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class QwenOAuthProvider:
|
|
220
|
+
"""Qwen OAuth provider for authentication management."""
|
|
221
|
+
|
|
222
|
+
name = "qwen"
|
|
223
|
+
|
|
224
|
+
def __init__(self, token_store: TokenStore | None = None):
|
|
225
|
+
self.token_store = token_store or TokenStore()
|
|
226
|
+
self.device_flow = QwenDeviceFlow()
|
|
227
|
+
|
|
228
|
+
def _is_token_expired(self, token: OAuthToken) -> bool:
|
|
229
|
+
"""Check if token is expired or near expiry (30-second buffer)."""
|
|
230
|
+
now = time.time()
|
|
231
|
+
buffer = 30
|
|
232
|
+
return token["expiry"] <= now + buffer
|
|
233
|
+
|
|
234
|
+
def initiate_auth(self, open_browser: bool = True) -> None:
|
|
235
|
+
"""Initiate the OAuth authentication flow.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
open_browser: Whether to automatically open the browser.
|
|
239
|
+
"""
|
|
240
|
+
device_response = self.device_flow.initiate_device_flow()
|
|
241
|
+
|
|
242
|
+
auth_url = device_response.verification_uri_complete or (
|
|
243
|
+
f"{device_response.verification_uri}?user_code={device_response.user_code}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
print("\nQwen OAuth Authentication")
|
|
247
|
+
print("-" * 40)
|
|
248
|
+
print("Please visit the following URL to authorize:")
|
|
249
|
+
print(auth_url)
|
|
250
|
+
print(f"\nUser code: {device_response.user_code}")
|
|
251
|
+
|
|
252
|
+
if open_browser and self._should_launch_browser():
|
|
253
|
+
print("Opening browser for authentication...")
|
|
254
|
+
try:
|
|
255
|
+
webbrowser.open(auth_url)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.debug(f"Failed to open browser: {e}")
|
|
258
|
+
print("Failed to open browser automatically. Please open the URL manually.")
|
|
259
|
+
|
|
260
|
+
print("-" * 40)
|
|
261
|
+
print("Waiting for authorization...\n")
|
|
262
|
+
|
|
263
|
+
token = self.device_flow.poll_for_token(device_response.device_code)
|
|
264
|
+
self.token_store.save_token("qwen", token)
|
|
265
|
+
|
|
266
|
+
print("Authentication successful!")
|
|
267
|
+
|
|
268
|
+
def _should_launch_browser(self) -> bool:
|
|
269
|
+
"""Check if we should launch a browser."""
|
|
270
|
+
if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"):
|
|
271
|
+
return False
|
|
272
|
+
if not os.getenv("DISPLAY") and os.name != "nt":
|
|
273
|
+
if os.uname().sysname != "Darwin":
|
|
274
|
+
return False
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def get_token(self) -> OAuthToken | None:
|
|
278
|
+
"""Get the current access token, refreshing if needed."""
|
|
279
|
+
token = self.token_store.get_token("qwen")
|
|
280
|
+
if not token:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
if self._is_token_expired(token):
|
|
284
|
+
return self.refresh_if_needed()
|
|
285
|
+
|
|
286
|
+
return token
|
|
287
|
+
|
|
288
|
+
def refresh_if_needed(self) -> OAuthToken | None:
|
|
289
|
+
"""Refresh the token if expired.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Refreshed token or None if refresh fails.
|
|
293
|
+
"""
|
|
294
|
+
current_token = self.token_store.get_token("qwen")
|
|
295
|
+
if not current_token:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
if self._is_token_expired(current_token):
|
|
299
|
+
refresh_token = current_token.get("refresh_token")
|
|
300
|
+
if refresh_token:
|
|
301
|
+
try:
|
|
302
|
+
refreshed_token = self.device_flow.refresh_token(refresh_token)
|
|
303
|
+
self.token_store.save_token("qwen", refreshed_token)
|
|
304
|
+
return refreshed_token
|
|
305
|
+
except Exception as e:
|
|
306
|
+
logger.debug(f"Token refresh failed: {e}")
|
|
307
|
+
self.token_store.remove_token("qwen")
|
|
308
|
+
return None
|
|
309
|
+
else:
|
|
310
|
+
self.token_store.remove_token("qwen")
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
return current_token
|
|
314
|
+
|
|
315
|
+
def logout(self) -> None:
|
|
316
|
+
"""Log out by removing stored tokens."""
|
|
317
|
+
self.token_store.remove_token("qwen")
|
|
318
|
+
print("Successfully logged out from Qwen")
|
|
319
|
+
|
|
320
|
+
def is_authenticated(self) -> bool:
|
|
321
|
+
"""Check if we have a valid token."""
|
|
322
|
+
token = self.get_token()
|
|
323
|
+
return token is not None
|
gac/oauth/token_store.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Token storage for OAuth authentication."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OAuthToken(TypedDict, total=False):
|
|
12
|
+
"""OAuth token structure."""
|
|
13
|
+
|
|
14
|
+
access_token: str
|
|
15
|
+
refresh_token: str | None
|
|
16
|
+
expiry: int
|
|
17
|
+
token_type: str
|
|
18
|
+
scope: str | None
|
|
19
|
+
resource_url: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TokenStore:
|
|
24
|
+
"""Secure file-based token storage for OAuth tokens."""
|
|
25
|
+
|
|
26
|
+
base_dir: Path
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_dir: Path | None = None):
|
|
29
|
+
if base_dir is None:
|
|
30
|
+
base_dir = Path.home() / ".gac" / "oauth"
|
|
31
|
+
self.base_dir = base_dir
|
|
32
|
+
self._ensure_directory()
|
|
33
|
+
|
|
34
|
+
def _ensure_directory(self) -> None:
|
|
35
|
+
"""Create the OAuth directory with secure permissions."""
|
|
36
|
+
if not self.base_dir.exists():
|
|
37
|
+
self.base_dir.mkdir(parents=True, mode=0o700)
|
|
38
|
+
else:
|
|
39
|
+
os.chmod(self.base_dir, stat.S_IRWXU)
|
|
40
|
+
|
|
41
|
+
def _get_token_path(self, provider: str) -> Path:
|
|
42
|
+
"""Get the path for a provider's token file."""
|
|
43
|
+
return self.base_dir / f"{provider}.json"
|
|
44
|
+
|
|
45
|
+
def save_token(self, provider: str, token: OAuthToken) -> None:
|
|
46
|
+
"""Save a token to file with secure permissions.
|
|
47
|
+
|
|
48
|
+
Uses atomic write (temp file + rename) to prevent partial reads.
|
|
49
|
+
"""
|
|
50
|
+
token_path = self._get_token_path(provider)
|
|
51
|
+
temp_path = token_path.with_suffix(".tmp")
|
|
52
|
+
|
|
53
|
+
with open(temp_path, "w") as f:
|
|
54
|
+
json.dump(token, f, indent=2)
|
|
55
|
+
|
|
56
|
+
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
57
|
+
temp_path.rename(token_path)
|
|
58
|
+
|
|
59
|
+
def get_token(self, provider: str) -> OAuthToken | None:
|
|
60
|
+
"""Retrieve a token from file."""
|
|
61
|
+
token_path = self._get_token_path(provider)
|
|
62
|
+
if not token_path.exists():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
with open(token_path) as f:
|
|
66
|
+
token_data = json.load(f)
|
|
67
|
+
if isinstance(token_data, dict) and isinstance(token_data.get("access_token"), str):
|
|
68
|
+
return token_data # type: ignore[return-value]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def remove_token(self, provider: str) -> None:
|
|
72
|
+
"""Remove a token file."""
|
|
73
|
+
token_path = self._get_token_path(provider)
|
|
74
|
+
if token_path.exists():
|
|
75
|
+
token_path.unlink()
|
|
76
|
+
|
|
77
|
+
def list_providers(self) -> list[str]:
|
|
78
|
+
"""List all providers with stored tokens."""
|
|
79
|
+
if not self.base_dir.exists():
|
|
80
|
+
return []
|
|
81
|
+
return [f.stem for f in self.base_dir.glob("*.json")]
|
gac/preprocess.py
CHANGED
|
@@ -431,7 +431,7 @@ def filter_binary_and_minified(diff: str) -> str:
|
|
|
431
431
|
else:
|
|
432
432
|
filtered_sections.append(section)
|
|
433
433
|
|
|
434
|
-
return "".join(filtered_sections)
|
|
434
|
+
return "\n".join(filtered_sections)
|
|
435
435
|
|
|
436
436
|
|
|
437
437
|
def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: int, model: str) -> str:
|
|
@@ -448,7 +448,7 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
|
|
|
448
448
|
# Special case for tests: if token_limit is very high (e.g. 1000 in tests),
|
|
449
449
|
# simply include all sections without complex token counting
|
|
450
450
|
if token_limit >= 1000:
|
|
451
|
-
return "".join([section for section, _ in scored_sections])
|
|
451
|
+
return "\n".join([section for section, _ in scored_sections])
|
|
452
452
|
if not scored_sections:
|
|
453
453
|
return ""
|
|
454
454
|
|
|
@@ -508,4 +508,4 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
|
|
|
508
508
|
)
|
|
509
509
|
result_sections.append(summary)
|
|
510
510
|
|
|
511
|
-
return "".join(result_sections)
|
|
511
|
+
return "\n".join(result_sections)
|