arch-ops-server 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.
- arch_ops_server/__init__.py +79 -0
- arch_ops_server/aur.py +1132 -0
- arch_ops_server/pacman.py +319 -0
- arch_ops_server/py.typed +0 -0
- arch_ops_server/server.py +672 -0
- arch_ops_server/utils.py +272 -0
- arch_ops_server/wiki.py +244 -0
- arch_ops_server-0.1.0.dist-info/METADATA +109 -0
- arch_ops_server-0.1.0.dist-info/RECORD +11 -0
- arch_ops_server-0.1.0.dist-info/WHEEL +4 -0
- arch_ops_server-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pacman/Official Repository interface module.
|
|
3
|
+
Provides package info and update checks with hybrid local/remote approach.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import Dict, Any, List, Optional
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .utils import (
|
|
12
|
+
IS_ARCH,
|
|
13
|
+
run_command,
|
|
14
|
+
create_error_response,
|
|
15
|
+
check_command_exists
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Arch Linux package API
|
|
21
|
+
ARCH_PACKAGES_API = "https://archlinux.org/packages/search/json/"
|
|
22
|
+
|
|
23
|
+
# HTTP client settings
|
|
24
|
+
DEFAULT_TIMEOUT = 10.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_official_package_info(package_name: str) -> Dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Get information about an official repository package.
|
|
30
|
+
|
|
31
|
+
Uses hybrid approach:
|
|
32
|
+
- If on Arch Linux: Execute `pacman -Si` for local database query
|
|
33
|
+
- Otherwise: Query archlinux.org API
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
package_name: Package name
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict with package information
|
|
40
|
+
"""
|
|
41
|
+
logger.info(f"Fetching info for official package: {package_name}")
|
|
42
|
+
|
|
43
|
+
# Try local pacman first if on Arch
|
|
44
|
+
if IS_ARCH and check_command_exists("pacman"):
|
|
45
|
+
info = await _get_package_info_local(package_name)
|
|
46
|
+
if info is not None:
|
|
47
|
+
return info
|
|
48
|
+
logger.warning(f"Local pacman query failed for {package_name}, trying remote API")
|
|
49
|
+
|
|
50
|
+
# Fallback to remote API
|
|
51
|
+
return await _get_package_info_remote(package_name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _get_package_info_local(package_name: str) -> Optional[Dict[str, Any]]:
|
|
55
|
+
"""
|
|
56
|
+
Query package info using local pacman command.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
package_name: Package name
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Package info dict or None if failed
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
exit_code, stdout, stderr = await run_command(
|
|
66
|
+
["pacman", "-Si", package_name],
|
|
67
|
+
timeout=5,
|
|
68
|
+
check=False
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if exit_code != 0:
|
|
72
|
+
logger.debug(f"pacman -Si failed for {package_name}")
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
# Parse pacman output
|
|
76
|
+
info = _parse_pacman_output(stdout)
|
|
77
|
+
|
|
78
|
+
if info:
|
|
79
|
+
info["source"] = "local"
|
|
80
|
+
logger.info(f"Successfully fetched {package_name} info locally")
|
|
81
|
+
return info
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(f"Local pacman query failed: {e}")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _get_package_info_remote(package_name: str) -> Dict[str, Any]:
|
|
91
|
+
"""
|
|
92
|
+
Query package info using archlinux.org API.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
package_name: Package name
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Package info dict or error response
|
|
99
|
+
"""
|
|
100
|
+
params = {
|
|
101
|
+
"name": package_name,
|
|
102
|
+
"exact": "on" # Exact match only
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
|
|
107
|
+
response = await client.get(ARCH_PACKAGES_API, params=params)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
|
|
110
|
+
data = response.json()
|
|
111
|
+
results = data.get("results", [])
|
|
112
|
+
|
|
113
|
+
if not results:
|
|
114
|
+
return create_error_response(
|
|
115
|
+
"NotFound",
|
|
116
|
+
f"Official package '{package_name}' not found in repositories"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Take first exact match (there should only be one)
|
|
120
|
+
pkg = results[0]
|
|
121
|
+
|
|
122
|
+
info = {
|
|
123
|
+
"source": "remote",
|
|
124
|
+
"name": pkg.get("pkgname"),
|
|
125
|
+
"repository": pkg.get("repo"),
|
|
126
|
+
"version": pkg.get("pkgver"),
|
|
127
|
+
"release": pkg.get("pkgrel"),
|
|
128
|
+
"epoch": pkg.get("epoch"),
|
|
129
|
+
"description": pkg.get("pkgdesc"),
|
|
130
|
+
"url": pkg.get("url"),
|
|
131
|
+
"architecture": pkg.get("arch"),
|
|
132
|
+
"maintainers": pkg.get("maintainers", []),
|
|
133
|
+
"packager": pkg.get("packager"),
|
|
134
|
+
"build_date": pkg.get("build_date"),
|
|
135
|
+
"last_update": pkg.get("last_update"),
|
|
136
|
+
"licenses": pkg.get("licenses", []),
|
|
137
|
+
"groups": pkg.get("groups", []),
|
|
138
|
+
"provides": pkg.get("provides", []),
|
|
139
|
+
"depends": pkg.get("depends", []),
|
|
140
|
+
"optdepends": pkg.get("optdepends", []),
|
|
141
|
+
"conflicts": pkg.get("conflicts", []),
|
|
142
|
+
"replaces": pkg.get("replaces", []),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
logger.info(f"Successfully fetched {package_name} info remotely")
|
|
146
|
+
|
|
147
|
+
return info
|
|
148
|
+
|
|
149
|
+
except httpx.TimeoutException:
|
|
150
|
+
logger.error(f"Remote package info fetch timed out for: {package_name}")
|
|
151
|
+
return create_error_response(
|
|
152
|
+
"TimeoutError",
|
|
153
|
+
f"Package info fetch timed out for: {package_name}"
|
|
154
|
+
)
|
|
155
|
+
except httpx.HTTPStatusError as e:
|
|
156
|
+
logger.error(f"Remote package info HTTP error: {e}")
|
|
157
|
+
return create_error_response(
|
|
158
|
+
"HTTPError",
|
|
159
|
+
f"Package info fetch failed with status {e.response.status_code}",
|
|
160
|
+
str(e)
|
|
161
|
+
)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"Remote package info fetch failed: {e}")
|
|
164
|
+
return create_error_response(
|
|
165
|
+
"InfoError",
|
|
166
|
+
f"Failed to get package info: {str(e)}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_pacman_output(output: str) -> Optional[Dict[str, Any]]:
|
|
171
|
+
"""
|
|
172
|
+
Parse pacman -Si output into structured dict.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
output: Raw pacman -Si output
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Parsed package info or None
|
|
179
|
+
"""
|
|
180
|
+
if not output.strip():
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
info = {}
|
|
184
|
+
current_key = None
|
|
185
|
+
|
|
186
|
+
for line in output.split('\n'):
|
|
187
|
+
# Match "Key : Value" pattern
|
|
188
|
+
match = re.match(r'^(\w[\w\s]*?)\s*:\s*(.*)$', line)
|
|
189
|
+
if match:
|
|
190
|
+
key = match.group(1).strip().lower().replace(' ', '_')
|
|
191
|
+
value = match.group(2).strip()
|
|
192
|
+
|
|
193
|
+
# Handle special fields
|
|
194
|
+
if key in ['depends_on', 'optional_deps', 'required_by',
|
|
195
|
+
'conflicts_with', 'replaces', 'groups', 'provides']:
|
|
196
|
+
# These can be multi-line or space-separated
|
|
197
|
+
if value.lower() == 'none':
|
|
198
|
+
info[key] = []
|
|
199
|
+
else:
|
|
200
|
+
info[key] = [v.strip() for v in value.split() if v.strip()]
|
|
201
|
+
else:
|
|
202
|
+
info[key] = value
|
|
203
|
+
|
|
204
|
+
current_key = key
|
|
205
|
+
elif current_key and line.startswith(' '):
|
|
206
|
+
# Continuation line (indented)
|
|
207
|
+
continuation = line.strip()
|
|
208
|
+
if continuation and current_key in info:
|
|
209
|
+
if isinstance(info[current_key], list):
|
|
210
|
+
info[current_key].extend([v.strip() for v in continuation.split() if v.strip()])
|
|
211
|
+
else:
|
|
212
|
+
info[current_key] += ' ' + continuation
|
|
213
|
+
|
|
214
|
+
return info if info else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def check_updates_dry_run() -> Dict[str, Any]:
|
|
218
|
+
"""
|
|
219
|
+
Check for available system updates without applying them.
|
|
220
|
+
|
|
221
|
+
Only works on Arch Linux systems with checkupdates command.
|
|
222
|
+
Requires pacman-contrib package.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Dict with list of available updates or error response
|
|
226
|
+
"""
|
|
227
|
+
logger.info("Checking for system updates (dry run)")
|
|
228
|
+
|
|
229
|
+
# Only supported on Arch Linux
|
|
230
|
+
if not IS_ARCH:
|
|
231
|
+
return create_error_response(
|
|
232
|
+
"NotSupported",
|
|
233
|
+
"Update checking is only supported on Arch Linux systems",
|
|
234
|
+
"This server is not running on Arch Linux"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Check if checkupdates command exists
|
|
238
|
+
if not check_command_exists("checkupdates"):
|
|
239
|
+
return create_error_response(
|
|
240
|
+
"CommandNotFound",
|
|
241
|
+
"checkupdates command not found",
|
|
242
|
+
"Install pacman-contrib package: pacman -S pacman-contrib"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
exit_code, stdout, stderr = await run_command(
|
|
247
|
+
["checkupdates"],
|
|
248
|
+
timeout=30, # Can take longer for sync
|
|
249
|
+
check=False
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Exit code 0: updates available
|
|
253
|
+
# Exit code 2: no updates available
|
|
254
|
+
# Other: error
|
|
255
|
+
|
|
256
|
+
if exit_code == 2 or not stdout.strip():
|
|
257
|
+
logger.info("No updates available")
|
|
258
|
+
return {
|
|
259
|
+
"updates_available": False,
|
|
260
|
+
"count": 0,
|
|
261
|
+
"packages": []
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if exit_code != 0:
|
|
265
|
+
logger.error(f"checkupdates failed with code {exit_code}: {stderr}")
|
|
266
|
+
return create_error_response(
|
|
267
|
+
"CommandError",
|
|
268
|
+
f"checkupdates command failed: {stderr}",
|
|
269
|
+
f"Exit code: {exit_code}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Parse checkupdates output
|
|
273
|
+
updates = _parse_checkupdates_output(stdout)
|
|
274
|
+
|
|
275
|
+
logger.info(f"Found {len(updates)} available updates")
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"updates_available": True,
|
|
279
|
+
"count": len(updates),
|
|
280
|
+
"packages": updates
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"Update check failed: {e}")
|
|
285
|
+
return create_error_response(
|
|
286
|
+
"UpdateCheckError",
|
|
287
|
+
f"Failed to check for updates: {str(e)}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_checkupdates_output(output: str) -> List[Dict[str, str]]:
|
|
292
|
+
"""
|
|
293
|
+
Parse checkupdates command output.
|
|
294
|
+
|
|
295
|
+
Format: "package current_version -> new_version"
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
output: Raw checkupdates output
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of update dicts
|
|
302
|
+
"""
|
|
303
|
+
updates = []
|
|
304
|
+
|
|
305
|
+
for line in output.strip().split('\n'):
|
|
306
|
+
if not line.strip():
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# Match pattern: "package old_ver -> new_ver"
|
|
310
|
+
match = re.match(r'^(\S+)\s+(\S+)\s+->\s+(\S+)$', line)
|
|
311
|
+
if match:
|
|
312
|
+
updates.append({
|
|
313
|
+
"package": match.group(1),
|
|
314
|
+
"current_version": match.group(2),
|
|
315
|
+
"new_version": match.group(3)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return updates
|
|
319
|
+
|
arch_ops_server/py.typed
ADDED
|
File without changes
|