factorio-mod-workshop 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.
fmw/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Workspace discovery and configuration.
2
+
3
+ All mod-specific and personal data lives in a *workspace* directory that is
4
+ deliberately kept outside this repository (see `fmw help workspace`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass
12
+ from importlib.resources import as_file, files
13
+ from pathlib import Path
14
+ from typing import ContextManager
15
+
16
+ import yaml
17
+
18
+ try: # libyaml is ~5x faster and ships with the PyYAML wheel on common platforms
19
+ from yaml import CSafeLoader as _Loader
20
+ except ImportError: # pragma: no cover - pure-python fallback
21
+ from yaml import SafeLoader as _Loader
22
+
23
+
24
+ def yaml_load(text: str):
25
+ """Parse YAML with the fastest available loader."""
26
+ return yaml.load(text, Loader=_Loader)
27
+
28
+
29
+ WORKSPACE_ENV = "FMW_WORKSPACE"
30
+
31
+
32
+ class WorkspaceError(RuntimeError):
33
+ pass
34
+
35
+
36
+ def template_dir() -> ContextManager[Path]:
37
+ """The bundled workspace scaffold, as a context manager yielding a path.
38
+
39
+ Resolved as a package resource rather than by walking up from __file__, so
40
+ it works identically for editable installs, wheels and zipapps, and does
41
+ not silently break when the package layout changes.
42
+ """
43
+ return as_file(files(__package__) / "templates" / "workspace")
44
+
45
+
46
+ def locate_workspace(explicit: str | None = None) -> Path:
47
+ """Resolve the workspace directory.
48
+
49
+ Priority: --workspace, then $FMW_WORKSPACE. There is deliberately no
50
+ implicit fallback: a workspace is real data, and guessing which one is
51
+ meant is worse than saying so.
52
+ """
53
+ for source, value in (("--workspace", explicit), (f"${WORKSPACE_ENV}", os.environ.get(WORKSPACE_ENV))):
54
+ if not value:
55
+ continue
56
+ path = Path(value).expanduser()
57
+ if not path.is_dir():
58
+ raise WorkspaceError(f"{source} points at {path}, which does not exist. Create it with `fmw init {path}`.")
59
+ return path.resolve()
60
+ raise WorkspaceError(
61
+ f"No workspace given. Set ${WORKSPACE_ENV}, pass --workspace PATH, or run "
62
+ "`fmw launcher` once to get a wrapper that pins one."
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class Workspace:
68
+ root: Path
69
+
70
+ @classmethod
71
+ def open(cls, explicit: str | None = None) -> "Workspace":
72
+ return cls(locate_workspace(explicit))
73
+
74
+ # --- paths ---
75
+ @property
76
+ def mods_file(self) -> Path:
77
+ return self.root / "mods.yaml"
78
+
79
+ @property
80
+ def config_file(self) -> Path:
81
+ return self.root / "config.yaml"
82
+
83
+ @property
84
+ def paths_file(self) -> Path:
85
+ return self.root / "paths.yaml"
86
+
87
+ @property
88
+ def threads_dir(self) -> Path:
89
+ return self.root / "threads"
90
+
91
+ @property
92
+ def cache_dir(self) -> Path:
93
+ return self.root / "cache"
94
+
95
+ @property
96
+ def tracker_dir(self) -> Path:
97
+ return self.root / "tracker"
98
+
99
+ def mod_cache_dir(self, mod: str) -> Path:
100
+ return self.cache_dir / mod
101
+
102
+ def thread_file(self, mod: str, thread_id: str) -> Path:
103
+ return self.mod_cache_dir(mod) / "threads" / f"{thread_id}.json"
104
+
105
+ def list_file(self, mod: str) -> Path:
106
+ return self.mod_cache_dir(mod) / "threads.json"
107
+
108
+ def mod_tracker_dir(self, mod: str) -> Path:
109
+ return self.tracker_dir / mod
110
+
111
+ # --- documents ---
112
+ def load_config(self) -> dict:
113
+ if not self.config_file.exists():
114
+ return {}
115
+ return yaml_load(self.config_file.read_text()) or {}
116
+
117
+ def mod_repos(self) -> list[str]:
118
+ return [str(r) for r in (self.load_config().get("mod_repos") or [])]
119
+
120
+ def edit_mod_repos(self, add: str | None = None, remove: str | None = None) -> list[str]:
121
+ """Add or remove a repository, editing config.yaml in place.
122
+
123
+ Line-oriented rather than parse-and-dump on purpose: config.yaml is
124
+ hand-written and full of comments, and PyYAML cannot round-trip those.
125
+ Rewriting the file would silently delete the explanations it ships with.
126
+ """
127
+ if not self.config_file.exists():
128
+ self.config_file.write_text("mod_repos:\n")
129
+ lines = self.config_file.read_text().splitlines()
130
+
131
+ head = next((i for i, l in enumerate(lines)
132
+ if re.match(r"^mod_repos:\s*(\[\s*\])?\s*$", l)), None)
133
+ if head is None:
134
+ lines += ["", "mod_repos:"]
135
+ head = len(lines) - 1
136
+ lines[head] = "mod_repos:" # normalise an empty `mod_repos: []`
137
+
138
+ end = head + 1
139
+ while end < len(lines) and re.match(r"^\s*-\s+\S", lines[end]):
140
+ end += 1
141
+ items = lines[head + 1:end]
142
+
143
+ if remove is not None:
144
+ target = str(Path(remove).expanduser())
145
+ items = [i for i in items
146
+ if str(Path(i.strip()[1:].strip()).expanduser()) != target]
147
+ if add is not None:
148
+ existing = {str(Path(i.strip()[1:].strip()).expanduser()) for i in items}
149
+ if str(Path(add).expanduser()) not in existing:
150
+ items.append(f" - {add}")
151
+
152
+ self.config_file.write_text("\n".join(lines[:head + 1] + items + lines[end:]) + "\n")
153
+ return [i.strip()[1:].strip() for i in items]
154
+
155
+ def load_mods(self) -> dict:
156
+ if not self.mods_file.exists():
157
+ return {"mods": {}}
158
+ data = yaml_load(self.mods_file.read_text()) or {}
159
+ data.setdefault("mods", {})
160
+ from .discover import normalise_maintained # local: avoids a cycle
161
+ for entry in data["mods"].values():
162
+ if "maintained" in entry:
163
+ entry["maintained"] = normalise_maintained(entry["maintained"])
164
+ return data
165
+
166
+ def load_paths(self) -> dict[str, str]:
167
+ """Local checkout paths, keyed by mod name. Machine-specific."""
168
+ if not self.paths_file.exists():
169
+ return {}
170
+ data = yaml_load(self.paths_file.read_text()) or {}
171
+ return data.get("paths") or {}
172
+
173
+ def save_paths(self, paths: dict[str, str]) -> None:
174
+ header = (
175
+ "# Where each mod is checked out on THIS machine. Generated by `fmw discover`.\n"
176
+ "# Kept out of mods.yaml so the registry can be shared; gitignored by default.\n"
177
+ )
178
+ self.paths_file.write_text(
179
+ header + yaml.safe_dump({"paths": dict(sorted(paths.items()))},
180
+ sort_keys=False, allow_unicode=True, width=100)
181
+ )
182
+
183
+ def save_mods(self, data: dict) -> None:
184
+ self.mods_file.parent.mkdir(parents=True, exist_ok=True)
185
+ self.mods_file.write_text(
186
+ yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=100)
187
+ )
fmw/discover.py ADDED
@@ -0,0 +1,193 @@
1
+ """Build the mod registry by scanning local repositories and the portal API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .config import Workspace
9
+ from .portal import NotFound, Portal, PortalError
10
+
11
+
12
+ SKIP_DIRS = {"node_modules", ".git", ".venv", "__pycache__"}
13
+
14
+
15
+ def scan_local(
16
+ roots: list[Path], max_depth: int = 3
17
+ ) -> tuple[list[dict], list[str]]:
18
+ """Find Factorio mods under the given roots, plus a list of warnings.
19
+
20
+ A mod is any directory holding a readable info.json with a `name` and a
21
+ `version`, which is what the game itself requires. Returns entries and
22
+ warnings describing everything that was skipped, so a silently ignored
23
+ typo in an info.json does not look like a mod that simply is not there.
24
+ """
25
+ found: list[dict] = []
26
+ warnings: list[str] = []
27
+ seen: dict[str, Path] = {}
28
+ for root in roots:
29
+ root = root.expanduser().resolve()
30
+ if not root.is_dir():
31
+ warnings.append(f"{root}: not a directory, skipped")
32
+ continue
33
+ for info_path in sorted(root.rglob("info.json")):
34
+ rel = info_path.relative_to(root)
35
+ if len(rel.parts) > max_depth:
36
+ continue
37
+ # Only the part below the root matters; the root's own ancestors
38
+ # may legitimately contain any of these names.
39
+ if any(part in SKIP_DIRS for part in rel.parts):
40
+ continue
41
+ try:
42
+ info = json.loads(info_path.read_text())
43
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as exc:
44
+ warnings.append(f"{info_path}: unreadable ({type(exc).__name__}), skipped")
45
+ continue
46
+ name = info.get("name")
47
+ if not name or "version" not in info:
48
+ warnings.append(f"{info_path}: no 'name' or 'version', not a mod, skipped")
49
+ continue
50
+ if name in seen:
51
+ warnings.append(
52
+ f"mod '{name}' found twice: using {seen[name]}, ignoring {info_path.parent}"
53
+ )
54
+ continue
55
+ seen[name] = info_path.parent
56
+ found.append(
57
+ {
58
+ "name": name,
59
+ "title": info.get("title", name),
60
+ "local_version": info.get("version"),
61
+ "factorio_version": str(info.get("factorio_version", "")),
62
+ "path": str(info_path.parent),
63
+ "repo": str(root),
64
+ "contact": info.get("contact", ""),
65
+ "homepage": info.get("homepage", ""),
66
+ }
67
+ )
68
+ return found, warnings
69
+
70
+
71
+ def enrich_from_portal(portal: Portal, entries: list[dict]) -> list[str]:
72
+ """Add portal-side facts to each registry entry. Returns warnings."""
73
+ warnings: list[str] = []
74
+ for e in entries:
75
+ try:
76
+ info = portal.mod_info(e["name"])
77
+ except NotFound:
78
+ e["on_portal"] = False
79
+ warnings.append(f"{e['name']}: not published on the mod portal")
80
+ continue
81
+ except PortalError as exc:
82
+ # A network failure must not be recorded as "unpublished" - leave
83
+ # the previous answer alone and say the lookup did not happen.
84
+ warnings.append(f"{e['name']}: portal lookup failed ({exc})")
85
+ continue
86
+ e["on_portal"] = True
87
+ e["title"] = info.get("title", e.get("title"))
88
+ e["downloads"] = info.get("downloads_count")
89
+ e["category"] = info.get("category", "")
90
+ releases = info.get("releases") or []
91
+ if releases:
92
+ latest = releases[-1]
93
+ e["portal_version"] = latest.get("version")
94
+ e["portal_factorio_version"] = (latest.get("info_json") or {}).get(
95
+ "factorio_version", ""
96
+ )
97
+ e["portal_released_at"] = (latest.get("released_at") or "")[:10]
98
+ e["portal_url"] = f"https://mods.factorio.com/mod/{e['name']}"
99
+ return warnings
100
+
101
+
102
+ # Fields the local scan always knows, and so always refreshes.
103
+ LOCAL_FIELDS = {
104
+ "local_version": "",
105
+ "factorio_version": "",
106
+ }
107
+ # Fields only a portal lookup can supply. With --no-portal they are absent from
108
+ # the scanned entry, and must then be carried over from the previous registry
109
+ # rather than reset - otherwise an offline run silently discards them.
110
+ PORTAL_FIELDS = {
111
+ "portal_version": "",
112
+ "portal_factorio_version": "",
113
+ "portal_released_at": "",
114
+ "downloads": 0,
115
+ "category": "",
116
+ "on_portal": None,
117
+ }
118
+ # Fields the maintainer owns; never touched by a scan.
119
+ MANUAL_FIELDS = ("maintained", "notes", "discord", "aliases", "priority")
120
+
121
+ # Deliberately words YAML cannot coerce. `yes` and `no` are parsed by YAML 1.1
122
+ # as booleans, which silently broke every string comparison against this field.
123
+ MAINTAINED = ("active", "frozen", "unknown")
124
+
125
+ LEGACY_MAINTAINED = {
126
+ True: "active", "yes": "active",
127
+ False: "frozen", "no": "frozen",
128
+ "frozen": "frozen",
129
+ }
130
+
131
+
132
+ def normalise_maintained(value) -> str:
133
+ """Map any historical spelling onto the current vocabulary."""
134
+ if isinstance(value, str):
135
+ value = value.strip().lower()
136
+ if value in LEGACY_MAINTAINED:
137
+ return LEGACY_MAINTAINED[value]
138
+ return value if value in MAINTAINED else "unknown"
139
+
140
+
141
+
142
+
143
+ def build_registry(existing: dict, entries: list[dict]) -> dict:
144
+ """Merge discovered entries into the registry, preserving what a scan cannot know."""
145
+ mods = existing.get("mods") or {}
146
+ for e in entries:
147
+ name = e["name"]
148
+ prev = mods.get(name) or {}
149
+ merged: dict = {
150
+ "title": e.get("title") or prev.get("title") or name,
151
+ "maintained": normalise_maintained(prev.get("maintained")),
152
+ "portal_url": f"https://mods.factorio.com/mod/{name}",
153
+ }
154
+ for field, default in LOCAL_FIELDS.items():
155
+ merged[field] = e.get(field, prev.get(field, default))
156
+ for field, default in PORTAL_FIELDS.items():
157
+ merged[field] = e[field] if field in e else prev.get(field, default)
158
+ for field in MANUAL_FIELDS:
159
+ if field in prev:
160
+ merged[field] = prev[field]
161
+ merged.setdefault("notes", "")
162
+ mods[name] = merged
163
+ existing["mods"] = dict(sorted(mods.items(), key=lambda kv: -(kv[1].get("downloads") or 0)))
164
+ return existing
165
+
166
+
167
+ def sync_paths(ws: Workspace, entries: list[dict]) -> dict[str, str]:
168
+ """Record where each mod lives on this machine, in paths.yaml.
169
+
170
+ Kept out of mods.yaml so the registry stays free of machine-specific data
171
+ and can be committed and shared. Paths recorded by an earlier version are
172
+ migrated out of the registry on the first run.
173
+ """
174
+ paths = ws.load_paths()
175
+ registry = ws.load_mods()["mods"]
176
+ for name, entry in registry.items():
177
+ if entry.get("path") and name not in paths:
178
+ paths[name] = entry["path"]
179
+ for e in entries:
180
+ if e.get("path"):
181
+ paths[e["name"]] = e["path"]
182
+ ws.save_paths(paths)
183
+ return paths
184
+
185
+
186
+ def strip_legacy_paths(registry: dict) -> bool:
187
+ """Remove the old per-mod `path` field. Returns True if anything changed."""
188
+ changed = False
189
+ for entry in registry.get("mods", {}).values():
190
+ if "path" in entry:
191
+ del entry["path"]
192
+ changed = True
193
+ return changed