larpfetch 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.
larpfetch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """larpfetch: LARP as any distro, hardware, or machine you want."""
2
+
3
+ __version__ = "0.1.0"
larpfetch/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running larpfetch as a module: python -m larpfetch."""
2
+
3
+ from larpfetch.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
larpfetch/cli.py ADDED
@@ -0,0 +1,172 @@
1
+ """CLI entry point for larpfetch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Sequence
8
+
9
+ from larpfetch import __version__
10
+ from larpfetch.collectors.common import collect_all
11
+ from larpfetch.config import (
12
+ get_appearance,
13
+ get_default_profile,
14
+ get_named_profiles,
15
+ load_config,
16
+ )
17
+ from larpfetch.renderer import render
18
+ from larpfetch.resolver import resolve
19
+
20
+
21
+ def _parse_sets(sets: list[str]) -> dict[str, str]:
22
+ """Parse repeated --set key=value arguments."""
23
+ result: dict[str, str] = {}
24
+ for s in sets:
25
+ if "=" not in s:
26
+ print(f"Error: --set argument must be key=value, got: {s}", file=sys.stderr)
27
+ sys.exit(1)
28
+ key, _, value = s.partition("=")
29
+ key = key.strip()
30
+ if not key:
31
+ print(f"Error: --set key cannot be empty in: {s}", file=sys.stderr)
32
+ sys.exit(1)
33
+ result[key] = value
34
+ return result
35
+
36
+
37
+ def _list_profiles(config: dict) -> None:
38
+ """Print available profiles."""
39
+ profiles = get_named_profiles(config)
40
+ if not profiles:
41
+ print("No profiles configured.")
42
+ return
43
+ print("Available profiles:")
44
+ for name in sorted(profiles.keys()):
45
+ fields = profiles[name]
46
+ desc = fields.get("os", fields.get("distro", "Custom identity"))
47
+ print(f" {name:20s} {desc}")
48
+
49
+
50
+ def _show_config(config: dict) -> None:
51
+ """Print the resolved configuration."""
52
+ default = get_default_profile(config)
53
+ profiles = get_named_profiles(config)
54
+ appearance = get_appearance(config)
55
+
56
+ print("Default profile:")
57
+ if default:
58
+ for k, v in sorted(default.items()):
59
+ print(f" {k} = {v}")
60
+ else:
61
+ print(" (empty)")
62
+
63
+ print("\nProfiles:")
64
+ if profiles:
65
+ for name in sorted(profiles.keys()):
66
+ print(f"\n [{name}]")
67
+ for k, v in sorted(profiles[name].items()):
68
+ print(f" {k} = {v}")
69
+ else:
70
+ print(" (none)")
71
+
72
+ print("\nAppearance:")
73
+ if appearance:
74
+ for k, v in sorted(appearance.items()):
75
+ print(f" {k} = {v}")
76
+ else:
77
+ print(" (defaults)")
78
+
79
+
80
+ def build_parser() -> argparse.ArgumentParser:
81
+ parser = argparse.ArgumentParser(
82
+ prog="larpfetch",
83
+ description=(
84
+ "LARP as any distro, hardware, or machine you want."
85
+ " Because reality is optional until you pass --real-shit."
86
+ ),
87
+ )
88
+ parser.add_argument("-p", "--profile", help="Select a named profile")
89
+ parser.add_argument(
90
+ "--real-shit",
91
+ action="store_true",
92
+ help="Show real detected system information only",
93
+ )
94
+ parser.add_argument(
95
+ "--list-profiles", action="store_true", help="List available profiles and exit"
96
+ )
97
+ parser.add_argument(
98
+ "--show-config", action="store_true", help="Show current configuration and exit"
99
+ )
100
+ parser.add_argument("--config", help="Path to a custom config file")
101
+ parser.add_argument(
102
+ "--set",
103
+ action="append",
104
+ default=[],
105
+ metavar="KEY=VALUE",
106
+ help="Override a field (repeatable)",
107
+ )
108
+ parser.add_argument(
109
+ "--version", action="version", version=f"larpfetch {__version__}"
110
+ )
111
+ return parser
112
+
113
+
114
+ def main(argv: Sequence[str] | None = None) -> None:
115
+ parser = build_parser()
116
+ args = parser.parse_args(argv)
117
+
118
+ # Load config
119
+ try:
120
+ config = load_config(args.config)
121
+ except FileNotFoundError as e:
122
+ print(f"Error: {e}", file=sys.stderr)
123
+ sys.exit(1)
124
+ except Exception as e:
125
+ print(f"Error loading config: {e}", file=sys.stderr)
126
+ sys.exit(1)
127
+
128
+ # --list-profiles
129
+ if args.list_profiles:
130
+ _list_profiles(config)
131
+ return
132
+
133
+ # --show-config
134
+ if args.show_config:
135
+ _show_config(config)
136
+ return
137
+
138
+ # Collect real system info
139
+ real = collect_all()
140
+
141
+ # Resolve display identity
142
+ default_profile = get_default_profile(config)
143
+ selected_profile = None
144
+ if args.profile:
145
+ profiles = get_named_profiles(config)
146
+ if args.profile not in profiles:
147
+ avail = ", ".join(sorted(profiles.keys()))
148
+ print(
149
+ f"Error: profile '{args.profile}' not found. Available: {avail}",
150
+ file=sys.stderr,
151
+ )
152
+ sys.exit(1)
153
+ selected_profile = profiles[args.profile]
154
+
155
+ cli_overrides = _parse_sets(args.set)
156
+ appearance = get_appearance(config)
157
+
158
+ resolved = resolve(
159
+ real=real,
160
+ default_profile=default_profile,
161
+ selected_profile=selected_profile,
162
+ cli_overrides=cli_overrides,
163
+ real_shit=args.real_shit,
164
+ )
165
+
166
+ # Render
167
+ output = render(resolved, real, args.real_shit, appearance)
168
+ print(output)
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
@@ -0,0 +1 @@
1
+ """Collectors package."""
@@ -0,0 +1,327 @@
1
+ """Cross-platform collectors using standard library and psutil."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+ import platform
8
+ import socket
9
+ import time
10
+ from collections import OrderedDict
11
+
12
+ try:
13
+ import psutil
14
+ except ImportError:
15
+ psutil = None # type: ignore[assignment]
16
+
17
+ from larpfetch.models import SystemInfo
18
+
19
+
20
+ def _fmt_bytes(n: int) -> str:
21
+ """Format bytes to human-readable string."""
22
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
23
+ if abs(n) < 1024:
24
+ return f"{n:.1f} {unit}"
25
+ n = int(n / 1024)
26
+ return f"{n} EiB"
27
+
28
+
29
+ def _fmt_uptime(seconds: float) -> str:
30
+ """Format seconds to a human-readable uptime string."""
31
+ days = int(seconds // 86400)
32
+ hours = int((seconds % 86400) // 3600)
33
+ minutes = int((seconds % 3600) // 60)
34
+ parts: list[str] = []
35
+ if days:
36
+ parts.append(f"{days}d")
37
+ if hours:
38
+ parts.append(f"{hours}h")
39
+ parts.append(f"{minutes}m")
40
+ return " ".join(parts)
41
+
42
+
43
+ def collect_common() -> SystemInfo:
44
+ """Collect information common across all platforms."""
45
+ info = SystemInfo(fields=OrderedDict())
46
+
47
+ # Username
48
+ try:
49
+ info.set("username", getpass.getuser())
50
+ except Exception:
51
+ pass
52
+
53
+ # Hostname
54
+ try:
55
+ info.set("hostname", socket.gethostname())
56
+ except Exception:
57
+ pass
58
+
59
+ # Architecture
60
+ try:
61
+ info.set("architecture", platform.machine())
62
+ except Exception:
63
+ pass
64
+
65
+ # Uptime
66
+ try:
67
+ if psutil is not None:
68
+ boot = psutil.boot_time()
69
+ info.set("uptime", _fmt_uptime(time.time() - boot))
70
+ except Exception:
71
+ pass
72
+
73
+ # Memory
74
+ try:
75
+ if psutil is not None:
76
+ vm = psutil.virtual_memory()
77
+ info.set("memory", f"{_fmt_bytes(vm.used)} / {_fmt_bytes(vm.total)}")
78
+ except Exception:
79
+ pass
80
+
81
+ # Disk
82
+ try:
83
+ if psutil is not None:
84
+ du = psutil.disk_usage("/")
85
+ info.set("disk", f"{_fmt_bytes(du.used)} / {_fmt_bytes(du.total)}")
86
+ except Exception:
87
+ pass
88
+
89
+ # Battery
90
+ try:
91
+ if psutil is not None:
92
+ bat = psutil.sensors_battery()
93
+ if bat is not None:
94
+ info.set("battery", f"{bat.percent}%")
95
+ except Exception:
96
+ pass
97
+
98
+ # CPU model
99
+ try:
100
+ info.set("cpu", _detect_cpu())
101
+ except Exception:
102
+ pass
103
+
104
+ return info
105
+
106
+
107
+ def _detect_cpu() -> str:
108
+ """Best-effort CPU name detection."""
109
+ # Linux: /proc/cpuinfo
110
+ try:
111
+ with open("/proc/cpuinfo") as f:
112
+ for line in f:
113
+ if line.startswith("model name"):
114
+ return line.split(":", 1)[1].strip()
115
+ except OSError:
116
+ pass
117
+
118
+ # macOS: sysctl
119
+ try:
120
+ import subprocess
121
+
122
+ result = subprocess.run(
123
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=3,
127
+ )
128
+ if result.returncode == 0:
129
+ return result.stdout.strip()
130
+ except Exception:
131
+ pass
132
+
133
+ # Windows: platform.processor()
134
+ proc = platform.processor()
135
+ if proc:
136
+ return proc
137
+
138
+ return "Unknown CPU"
139
+
140
+
141
+ def collect_platform() -> SystemInfo:
142
+ """Collect platform-specific information."""
143
+ system = platform.system()
144
+ if system == "Linux":
145
+ return _collect_linux()
146
+ elif system == "Darwin":
147
+ return _collect_macos()
148
+ elif system == "Windows":
149
+ return _collect_windows()
150
+ return SystemInfo(fields=OrderedDict())
151
+
152
+
153
+ def _collect_linux() -> SystemInfo:
154
+ info = SystemInfo(fields=OrderedDict())
155
+
156
+ # OS / distro from /etc/os-release
157
+ os_release: dict[str, str] = {}
158
+ try:
159
+ with open("/etc/os-release") as f:
160
+ for line in f:
161
+ line = line.strip()
162
+ if "=" in line:
163
+ key, _, value = line.partition("=")
164
+ os_release[key] = value.strip('"')
165
+ except OSError:
166
+ pass
167
+
168
+ if os_release.get("PRETTY_NAME"):
169
+ info.set("distro", os_release["PRETTY_NAME"])
170
+ info.set("os", os_release["PRETTY_NAME"])
171
+ elif os_release.get("NAME"):
172
+ info.set("distro", os_release["NAME"])
173
+ info.set("os", os_release["NAME"])
174
+
175
+ if os_release.get("VERSION"):
176
+ info.set("os_version", os_release["VERSION"])
177
+
178
+ # Kernel
179
+ try:
180
+ info.set("kernel", platform.release())
181
+ except Exception:
182
+ pass
183
+
184
+ # Shell
185
+ shell = os.environ.get("SHELL", "")
186
+ if shell:
187
+ info.set("shell", os.path.basename(shell))
188
+
189
+ # Desktop environment
190
+ de = os.environ.get("XDG_CURRENT_DESKTOP", "") or os.environ.get(
191
+ "DESKTOP_SESSION", ""
192
+ )
193
+ if de:
194
+ info.set("de", de)
195
+
196
+ # GPU via lspci (best-effort)
197
+ try:
198
+ import subprocess
199
+
200
+ result = subprocess.run(
201
+ ["lspci"],
202
+ capture_output=True,
203
+ text=True,
204
+ timeout=3,
205
+ )
206
+ if result.returncode == 0:
207
+ for line in result.stdout.splitlines():
208
+ lower = line.lower()
209
+ if "vga" in lower or "3d" in lower or "display" in lower:
210
+ # Format: "XX:XX.X Class: vendor device"
211
+ parts = line.split(": ", 2)
212
+ if len(parts) >= 3:
213
+ gpu_desc = parts[2]
214
+ # Trim the trailing (rev xx) if present
215
+ if " (" in gpu_desc:
216
+ gpu_desc = gpu_desc[: gpu_desc.rfind(" (")]
217
+ info.set("gpu", gpu_desc)
218
+ break
219
+ except Exception:
220
+ pass
221
+
222
+ return info
223
+
224
+
225
+ def _collect_macos() -> SystemInfo:
226
+ info = SystemInfo(fields=OrderedDict())
227
+
228
+ # macOS version
229
+ try:
230
+ mac_ver = platform.mac_ver()
231
+ if mac_ver and mac_ver[0]:
232
+ info.set("os", f"macOS {mac_ver[0]}")
233
+ info.set("distro", f"macOS {mac_ver[0]}")
234
+ info.set("os_version", mac_ver[0])
235
+ except Exception:
236
+ pass
237
+
238
+ # Kernel
239
+ try:
240
+ info.set("kernel", f"Darwin {platform.release()}")
241
+ except Exception:
242
+ pass
243
+
244
+ # Shell
245
+ shell = os.environ.get("SHELL", "")
246
+ if shell:
247
+ info.set("shell", os.path.basename(shell))
248
+
249
+ # Desktop environment (macOS = Aqua)
250
+ info.set("de", "Aqua")
251
+
252
+ # GPU via system_profiler (best-effort)
253
+ try:
254
+ import subprocess
255
+
256
+ result = subprocess.run(
257
+ ["system_profiler", "SPDisplaysDataType"],
258
+ capture_output=True,
259
+ text=True,
260
+ timeout=5,
261
+ )
262
+ if result.returncode == 0:
263
+ for line in result.stdout.splitlines():
264
+ if "Chipset Model:" in line or "Chip:" in line:
265
+ gpu = line.split(":", 1)[1].strip()
266
+ info.set("gpu", gpu)
267
+ break
268
+ except Exception:
269
+ pass
270
+
271
+ return info
272
+
273
+
274
+ def _collect_windows() -> SystemInfo:
275
+ info = SystemInfo(fields=OrderedDict())
276
+
277
+ # Windows version
278
+ try:
279
+ ver = platform.version()
280
+ release = platform.release()
281
+ info.set("os", f"Windows {release} {ver}")
282
+ info.set("os_version", f"{release} {ver}")
283
+ except Exception:
284
+ pass
285
+
286
+ # Kernel
287
+ try:
288
+ info.set("kernel", platform.platform())
289
+ except Exception:
290
+ pass
291
+
292
+ # Shell
293
+ info.set("shell", "powershell.exe")
294
+
295
+ # Desktop environment
296
+ info.set("de", "Desktop Window Manager")
297
+
298
+ # GPU via WMIC (best-effort)
299
+ try:
300
+ import subprocess
301
+
302
+ result = subprocess.run(
303
+ ["wmic", "path", "win32_videocontroller", "get", "name"],
304
+ capture_output=True,
305
+ text=True,
306
+ timeout=5,
307
+ )
308
+ if result.returncode == 0:
309
+ lines = [
310
+ line.strip()
311
+ for line in result.stdout.splitlines()
312
+ if line.strip() and line.strip() != "Name"
313
+ ]
314
+ if lines:
315
+ info.set("gpu", lines[0])
316
+ except Exception:
317
+ pass
318
+
319
+ return info
320
+
321
+
322
+ def collect_all() -> SystemInfo:
323
+ """Collect all available system information."""
324
+ info = collect_common()
325
+ platform_info = collect_platform()
326
+ info.update_from(platform_info.to_dict())
327
+ return info
@@ -0,0 +1,5 @@
1
+ """Linux-specific collector (imports from common)."""
2
+
3
+ from larpfetch.collectors.common import _collect_linux as collect_linux
4
+
5
+ __all__ = ["collect_linux"]
@@ -0,0 +1,5 @@
1
+ """macOS-specific collector (imports from common)."""
2
+
3
+ from larpfetch.collectors.common import _collect_macos as collect_macos
4
+
5
+ __all__ = ["collect_macos"]
@@ -0,0 +1,5 @@
1
+ """Windows-specific collector (imports from common)."""
2
+
3
+ from larpfetch.collectors.common import _collect_windows as collect_windows
4
+
5
+ __all__ = ["collect_windows"]
larpfetch/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """TOML configuration loading and management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ # Platform default config paths
12
+ if sys.platform == "darwin":
13
+ _DEFAULT_CONFIG_DIR = Path.home() / "Library" / "Application Support" / "larpfetch"
14
+ elif sys.platform == "win32":
15
+ _DEFAULT_CONFIG_DIR = Path(os.environ.get("APPDATA", "~")) / "larpfetch"
16
+ else:
17
+ _xdg = Path(os.environ.get("XDG_CONFIG_HOME", ""))
18
+ if _xdg.is_absolute():
19
+ _DEFAULT_CONFIG_DIR = _xdg / "larpfetch"
20
+ else:
21
+ _DEFAULT_CONFIG_DIR = Path.home() / ".config" / "larpfetch"
22
+
23
+ DEFAULT_CONFIG_PATH = _DEFAULT_CONFIG_DIR / "config.toml"
24
+
25
+
26
+ def _resolve_config_path(explicit: str | None = None) -> Path | None:
27
+ """Return the config path to use, or None if no config exists."""
28
+ if explicit:
29
+ p = Path(explicit)
30
+ if not p.is_file():
31
+ raise FileNotFoundError(f"Config file not found: {explicit}")
32
+ return p
33
+ if DEFAULT_CONFIG_PATH.is_file():
34
+ return DEFAULT_CONFIG_PATH
35
+ return None
36
+
37
+
38
+ def load_config(path: str | None = None) -> dict[str, Any]:
39
+ """Load and parse a TOML config file.
40
+
41
+ Returns a dict with keys: default, profiles, appearance.
42
+ Missing sections default to empty dicts.
43
+ Raises FileNotFoundError or tomllib.TOMLDecodeError on failure.
44
+ """
45
+ config_path = _resolve_config_path(path)
46
+ if config_path is None:
47
+ return {"default": {}, "profiles": {}, "appearance": {}}
48
+
49
+ with open(config_path, "rb") as f:
50
+ data = tomllib.load(f)
51
+
52
+ return {
53
+ "default": data.get("default", {}),
54
+ "profiles": data.get("profiles", {}),
55
+ "appearance": data.get("appearance", {}),
56
+ }
57
+
58
+
59
+ def get_default_profile(config: dict[str, Any]) -> dict[str, str]:
60
+ """Extract the default profile as a flat string dict."""
61
+ return {k: str(v) for k, v in config.get("default", {}).items()}
62
+
63
+
64
+ def get_named_profiles(config: dict[str, Any]) -> dict[str, dict[str, str]]:
65
+ """Extract all named profiles as flat string dicts."""
66
+ profiles: dict[str, dict[str, str]] = {}
67
+ for name, values in config.get("profiles", {}).items():
68
+ profiles[name] = {k: str(v) for k, v in values.items()}
69
+ return profiles
70
+
71
+
72
+ def get_appearance(config: dict[str, Any]) -> dict[str, Any]:
73
+ """Extract appearance settings."""
74
+ return config.get("appearance", {})
@@ -0,0 +1,92 @@
1
+ """Easter eggs and humor for larpfetch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+
8
+ from larpfetch.models import SystemInfo
9
+
10
+
11
+ def _is_implausible_memory(mem: str) -> bool:
12
+ """Check if a memory string is absurdly large (> 1 TiB)."""
13
+ match = re.search(r"([\d.]+)\s*(GiB|TiB|PiB|EiB)", mem, re.IGNORECASE)
14
+ if not match:
15
+ return False
16
+ value = float(match.group(1))
17
+ unit = match.group(2).lower()
18
+ if unit == "pib":
19
+ return True
20
+ if unit == "eib":
21
+ return True
22
+ if unit == "tib" and value >= 1:
23
+ return True
24
+ return False
25
+
26
+
27
+ def _compute_authenticity(real: SystemInfo, resolved: SystemInfo, real_shit: bool) -> int:
28
+ """Compute an authenticity percentage (0-100)."""
29
+ if real_shit:
30
+ return 100
31
+
32
+ real_d = real.to_dict()
33
+ resolved_d = resolved.to_dict()
34
+
35
+ if not real_d:
36
+ return 50
37
+
38
+ matches = sum(1 for k in real_d if k in resolved_d and real_d[k] == resolved_d[k])
39
+ total = max(len(real_d), 1)
40
+ pct = int((matches / total) * 100)
41
+ # In LARP mode, authenticity should be low if user is faking a lot
42
+ return max(0, min(100, pct))
43
+
44
+
45
+ def get_authenticity_line(
46
+ real: SystemInfo, resolved: SystemInfo, real_shit: bool, easter_eggs: bool
47
+ ) -> str | None:
48
+ """Return the authenticity line, or None if disabled."""
49
+ if not easter_eggs:
50
+ return None
51
+ pct = _compute_authenticity(real, resolved, real_shit)
52
+ return f"Authenticity: {pct}%"
53
+
54
+
55
+ def get_extra_lines(
56
+ resolved: SystemInfo, real: SystemInfo, real_shit: bool, easter_eggs: bool
57
+ ) -> list[str]:
58
+ """Return additional humorous or informational lines."""
59
+ if not easter_eggs:
60
+ return []
61
+
62
+ lines: list[str] = []
63
+
64
+ if real_shit:
65
+ lines.append("Source: reality (unfortunately)")
66
+ return lines
67
+
68
+ # Check for implausible memory
69
+ mem = resolved.get("memory", "")
70
+ if _is_implausible_memory(mem):
71
+ lines.append("Source: trust me bro")
72
+
73
+ # Check for absurdly high package count
74
+ pc = resolved.get("package_count", "")
75
+ if pc.isdigit() and int(pc) > 99999:
76
+ lines.append("Reality Leakage: 100.00%")
77
+
78
+ # Check if real hardware is better than LARP (out-LARP detection)
79
+ if not real_shit and real.fields:
80
+ # Conservative thresholds for "impressive" real hardware
81
+ real_mem = real.get("memory", "")
82
+ if _is_implausible_memory(real_mem):
83
+ lines.append("Reality has out-LARPed the LARP.")
84
+
85
+ # The allegations were true - deterministic based on username
86
+ username = resolved.get("username", "")
87
+ if username:
88
+ h = int(hashlib.md5(username.encode()).hexdigest()[:8], 16)
89
+ if h % 100 == 0: # 1% chance
90
+ lines.append("The allegations were true.")
91
+
92
+ return lines