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.
@@ -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
+
File without changes