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.
- factorio_mod_workshop-0.1.0.dist-info/METADATA +189 -0
- factorio_mod_workshop-0.1.0.dist-info/RECORD +20 -0
- factorio_mod_workshop-0.1.0.dist-info/WHEEL +4 -0
- factorio_mod_workshop-0.1.0.dist-info/entry_points.txt +2 -0
- factorio_mod_workshop-0.1.0.dist-info/licenses/LICENSE +617 -0
- fmw/__init__.py +3 -0
- fmw/cli.py +1431 -0
- fmw/config.py +187 -0
- fmw/discover.py +193 -0
- fmw/help.py +387 -0
- fmw/portal.py +336 -0
- fmw/store.py +141 -0
- fmw/templates/workspace/.gitignore +5 -0
- fmw/templates/workspace/README.md +31 -0
- fmw/templates/workspace/config.example.yaml +11 -0
- fmw/templates/workspace/config.yaml +11 -0
- fmw/templates/workspace/mods.yaml +6 -0
- fmw/templates/workspace/tracker/README.md +40 -0
- fmw/threadstate.py +150 -0
- fmw/tracker.py +223 -0
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
|